diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html
index 0183dedab8..abe160bc67 100644
--- a/packages/embeds/embed-core/index.html
+++ b/packages/embeds/embed-core/index.html
@@ -399,7 +399,16 @@
+
+
+
diff --git a/packages/embeds/embed-core/playground/lib/playground.ts b/packages/embeds/embed-core/playground/lib/playground.ts
index f243a69650..a468791aec 100644
--- a/packages/embeds/embed-core/playground/lib/playground.ts
+++ b/packages/embeds/embed-core/playground/lib/playground.ts
@@ -288,7 +288,7 @@ if (only === "all" || only === "inline-routing-form") {
]);
}
-if (only === "all" || only === "hideEventTypeDetails") {
+if (only === "all" || only === "ns:hideEventTypeDetails") {
const identifier = "hideEventTypeDetails";
Cal("init", identifier, {
debug: true,
@@ -693,6 +693,40 @@ if (only === "all" || only === "ns:routingFormWithoutPrerender") {
});
}
+if (only === "all" || only === "ns:containerScrollToTimeslot") {
+ Cal("init", "containerScrollToTimeslot", {
+ debug: true,
+ origin,
+ });
+ Cal.ns.containerScrollToTimeslot("inline", {
+ elementOrSelector: "#cal-booking-place-containerScrollToTimeslot .place",
+ calLink: "free/30min",
+ config: {
+ iframeAttrs: {
+ id: "cal-booking-place-containerScrollToTimeslot-iframe",
+ },
+ "flag.coep": "true",
+ },
+ });
+}
+
+if (only === "all" || only === "ns:windowScrollToTimeslot") {
+ Cal("init", "windowScrollToTimeslot", {
+ debug: true,
+ origin,
+ });
+ Cal.ns.windowScrollToTimeslot("inline", {
+ elementOrSelector: "#cal-booking-place-windowScrollToTimeslot .place",
+ calLink: "free/30min",
+ config: {
+ iframeAttrs: {
+ id: "cal-booking-place-windowScrollToTimeslot-iframe",
+ },
+ "flag.coep": "true",
+ },
+ });
+}
+
// Keep it at the bottom as it works on the API defined above for various cases
(function ensureScrolledToCorrectIframe() {
// Reset the hash so that we can scroll to correct iframe
diff --git a/packages/embeds/embed-core/src/Inline/inline.ts b/packages/embeds/embed-core/src/Inline/inline.ts
index 15a1c3481b..aef52ef5b4 100644
--- a/packages/embeds/embed-core/src/Inline/inline.ts
+++ b/packages/embeds/embed-core/src/Inline/inline.ts
@@ -1,6 +1,6 @@
import { EmbedElement } from "../EmbedElement";
+import { getErrorString } from "../lib/utils";
import loaderCss from "../loader.css?inline";
-import { getErrorString } from "../utils";
import inlineHtml, { getSkeletonData } from "./inlineHtml";
export class Inline extends EmbedElement {
diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts
index 2ac751a5a3..901cd22541 100644
--- a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts
+++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts
@@ -1,6 +1,6 @@
import { EmbedElement } from "../EmbedElement";
+import { getErrorString } from "../lib/utils";
import loaderCss from "../loader.css";
-import { getErrorString } from "../utils";
import modalBoxHtml, { getSkeletonData } from "./ModalBoxHtml";
export class ModalBox extends EmbedElement {
diff --git a/packages/embeds/embed-core/src/__tests__/utils.test.ts b/packages/embeds/embed-core/src/__tests__/utils.test.ts
index 7ad74c79dc..a54180f852 100644
--- a/packages/embeds/embed-core/src/__tests__/utils.test.ts
+++ b/packages/embeds/embed-core/src/__tests__/utils.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
-import { generateDataAttributes, isSameBookingLink } from "../utils";
+import { generateDataAttributes, isSameBookingLink } from "../lib/utils";
describe("generateDataAttributes", () => {
it("should handle PascalCase property names correctly", () => {
diff --git a/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts b/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts
index 003db7caf4..ab16c24dfd 100644
--- a/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts
+++ b/packages/embeds/embed-core/src/embed-iframe/lib/embedStore.ts
@@ -1,3 +1,4 @@
+import { isParamValuePresentInUrlSearchParams } from "../../lib/utils";
import type {
EmbedThemeConfig,
UiConfig,
@@ -6,7 +7,6 @@ import type {
SetStyles,
setNonStylesConfig,
} from "../../types";
-import { isParamValuePresentInUrlSearchParams } from "../../utils";
import { runAsap } from "./utils";
export const enum EMBED_IFRAME_STATE {
diff --git a/packages/embeds/embed-core/src/embed.ts b/packages/embeds/embed-core/src/embed.ts
index 86b68bf1cd..88e6e76789 100644
--- a/packages/embeds/embed-core/src/embed.ts
+++ b/packages/embeds/embed-core/src/embed.ts
@@ -10,11 +10,8 @@ import {
} from "./constants";
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
import css from "./embed.css";
-import { SdkActionManager } from "./sdk-action-manager";
-import type { EventData, EventDataMap } from "./sdk-action-manager";
-import tailwindCss from "./tailwindCss";
-import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig, ModalPrerenderOptions } from "./types";
-import { getMaxHeightForModal } from "./ui-utils";
+import { getScrollableAncestor } from "./lib/domUtils";
+import { getScrollByDistanceHandler } from "./lib/eventHandlers/scrollByDistanceEventHandler";
import {
fromEntriesWithDuplicateKeys,
isRouterPath,
@@ -22,7 +19,12 @@ import {
getConfigProp,
isSameBookingLink,
buildConfigWithPrerenderRelatedFields,
-} from "./utils";
+} from "./lib/utils";
+import { SdkActionManager } from "./sdk-action-manager";
+import type { EventData, EventDataMap } from "./sdk-action-manager";
+import tailwindCss from "./tailwindCss";
+import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig, ModalPrerenderOptions } from "./types";
+import { getMaxHeightForModal } from "./ui-utils";
// Exporting for consumption by @calcom/embed-core user
export type { EmbedEvent } from "./sdk-action-manager";
@@ -472,6 +474,8 @@ export class Cal {
}
});
+ this.actionManager.on("__scrollByDistance", getScrollByDistanceHandler(this));
+
this.actionManager.on("linkReady", () => {
if (this.isPrerendering) {
// Ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed could show-up without any user action
@@ -500,6 +504,20 @@ export class Cal {
});
}
+ scrollByDistance(distanceInPixels: number): void {
+ if (!this.iframe) {
+ return;
+ }
+ // We compute scrollable ancestor on demand here and not when the iframe is created because because we need to see if the ancestor has scrollable content at that time
+ const scrollContainer = getScrollableAncestor(this.iframe);
+ if (!scrollContainer) {
+ return;
+ }
+ const newScrollTop = scrollContainer.scrollTop + distanceInPixels;
+
+ scrollContainer.scrollTo({ top: newScrollTop, behavior: "smooth" });
+ }
+
private filterParams(params: Record): Record {
return Object.fromEntries(Object.entries(params).filter(([key, value]) => !excludeParam(key, value)));
}
diff --git a/packages/embeds/embed-core/src/lib/domUtils.test.ts b/packages/embeds/embed-core/src/lib/domUtils.test.ts
new file mode 100644
index 0000000000..c8113594fd
--- /dev/null
+++ b/packages/embeds/embed-core/src/lib/domUtils.test.ts
@@ -0,0 +1,463 @@
+import { describe, expect, it, beforeEach, vi, beforeAll, afterEach } from "vitest";
+
+import { getScrollableAncestor } from "./domUtils";
+
+function createMockElement(options: {
+ scrollHeight?: number;
+ clientHeight?: number;
+ overflowY?: string;
+ overflow?: string;
+ parentElement?: HTMLElement | null;
+}): HTMLElement {
+ const element = document.createElement("div");
+
+ // Set up scroll properties
+ Object.defineProperty(element, "scrollHeight", {
+ value: options.scrollHeight ?? 100,
+ writable: true,
+ });
+
+ Object.defineProperty(element, "clientHeight", {
+ value: options.clientHeight ?? 100,
+ writable: true,
+ });
+
+ // Set up parent relationship
+ if (options.parentElement !== undefined) {
+ Object.defineProperty(element, "parentElement", {
+ value: options.parentElement,
+ writable: true,
+ });
+ }
+
+ return element;
+}
+
+function mockGetComputedStyle(styleOverrides: Record> = {}) {
+ return vi.fn().mockImplementation((element: HTMLElement) => {
+ const elementKey = element.tagName + (element.id ? `#${element.id}` : "");
+ const styles = styleOverrides[elementKey] || {};
+
+ return {
+ getPropertyValue: vi.fn().mockImplementation((property: string) => {
+ return styles[property] || "visible";
+ }),
+ };
+ });
+}
+
+describe("getScrollableAncestor", () => {
+ let originalGetComputedStyle: typeof window.getComputedStyle;
+ let originalDocumentElement: Document["documentElement"];
+ let originalScrollingElement: Document["scrollingElement"];
+
+ beforeAll(() => {
+ originalGetComputedStyle = window.getComputedStyle;
+ originalDocumentElement = document.documentElement;
+ originalScrollingElement = document.scrollingElement;
+ });
+
+ beforeEach(() => {
+ // Reset document.scrollingElement to a default mock
+ Object.defineProperty(document, "scrollingElement", {
+ value: document.createElement("html"),
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ afterAll(() => {
+ window.getComputedStyle = originalGetComputedStyle;
+ Object.defineProperty(document, "documentElement", {
+ value: originalDocumentElement,
+ writable: true,
+ configurable: true,
+ });
+ Object.defineProperty(document, "scrollingElement", {
+ value: originalScrollingElement,
+ writable: true,
+ configurable: true,
+ });
+ });
+
+ describe("with scrollable ancestors", () => {
+ it("should return the first scrollable ancestor with overflow-y: auto", () => {
+ const scrollableParent = createMockElement({
+ scrollHeight: 200,
+ clientHeight: 100,
+ });
+ scrollableParent.id = "scrollable-parent";
+
+ const targetElement = createMockElement({
+ parentElement: scrollableParent,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ "DIV#scrollable-parent": {
+ "overflow-y": "auto",
+ overflow: "visible",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(scrollableParent);
+ });
+
+ it("should return the first scrollable ancestor with overflow-y: scroll", () => {
+ const scrollableParent = createMockElement({
+ scrollHeight: 200,
+ clientHeight: 100,
+ });
+ scrollableParent.id = "scrollable-parent";
+
+ const targetElement = createMockElement({
+ parentElement: scrollableParent,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ "DIV#scrollable-parent": {
+ "overflow-y": "scroll",
+ overflow: "visible",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(scrollableParent);
+ });
+
+ it("should return the first scrollable ancestor with overflow: auto", () => {
+ const scrollableParent = createMockElement({
+ scrollHeight: 200,
+ clientHeight: 100,
+ });
+ scrollableParent.id = "scrollable-parent";
+
+ const targetElement = createMockElement({
+ parentElement: scrollableParent,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ "DIV#scrollable-parent": {
+ "overflow-y": "visible",
+ overflow: "auto",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(scrollableParent);
+ });
+
+ it("should return the first scrollable ancestor with overflow: scroll", () => {
+ const scrollableParent = createMockElement({
+ scrollHeight: 200,
+ clientHeight: 100,
+ });
+ scrollableParent.id = "scrollable-parent";
+
+ const targetElement = createMockElement({
+ parentElement: scrollableParent,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ "DIV#scrollable-parent": {
+ "overflow-y": "visible",
+ overflow: "scroll",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(scrollableParent);
+ });
+
+ it("should skip non-scrollable ancestors and find the scrollable one", () => {
+ const scrollableGrandparent = createMockElement({
+ scrollHeight: 300,
+ clientHeight: 150,
+ });
+ scrollableGrandparent.id = "scrollable-grandparent";
+
+ const nonScrollableParent = createMockElement({
+ scrollHeight: 100,
+ clientHeight: 100,
+ parentElement: scrollableGrandparent,
+ });
+ nonScrollableParent.id = "non-scrollable-parent";
+
+ const targetElement = createMockElement({
+ parentElement: nonScrollableParent,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ "DIV#scrollable-grandparent": {
+ "overflow-y": "auto",
+ overflow: "visible",
+ },
+ "DIV#non-scrollable-parent": {
+ "overflow-y": "visible",
+ overflow: "visible",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(scrollableGrandparent);
+ });
+ });
+
+ describe("elements with scrollable styles but no scrollable content", () => {
+ it("should skip elements with overflow: auto but no scrollable content", () => {
+ const parentWithoutScrollableContent = createMockElement({
+ scrollHeight: 100,
+ clientHeight: 100, // Equal heights = no scrollable content
+ });
+ parentWithoutScrollableContent.id = "parent-no-content";
+
+ const targetElement = createMockElement({
+ parentElement: parentWithoutScrollableContent,
+ });
+
+ // Set parent's parent to null to reach the end of the chain
+ Object.defineProperty(parentWithoutScrollableContent, "parentElement", {
+ value: null,
+ writable: true,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ "DIV#parent-no-content": {
+ "overflow-y": "auto",
+ overflow: "visible",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(document.scrollingElement);
+ });
+
+ it("should skip elements with scrollHeight < clientHeight", () => {
+ const parentWithSmallerScrollHeight = createMockElement({
+ scrollHeight: 80,
+ clientHeight: 100, // clientHeight > scrollHeight = no scrollable content
+ });
+ parentWithSmallerScrollHeight.id = "parent-smaller-scroll";
+
+ const targetElement = createMockElement({
+ parentElement: parentWithSmallerScrollHeight,
+ });
+
+ Object.defineProperty(parentWithSmallerScrollHeight, "parentElement", {
+ value: null,
+ writable: true,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ "DIV#parent-smaller-scroll": {
+ "overflow-y": "scroll",
+ overflow: "visible",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(document.scrollingElement);
+ });
+ });
+
+ describe("fallback to document.scrollingElement", () => {
+ it("should return document.scrollingElement when no scrollable ancestors found", () => {
+ const nonScrollableParent = createMockElement({
+ scrollHeight: 100,
+ clientHeight: 100,
+ });
+ nonScrollableParent.id = "non-scrollable";
+
+ const targetElement = createMockElement({
+ parentElement: nonScrollableParent,
+ });
+
+ Object.defineProperty(nonScrollableParent, "parentElement", {
+ value: null,
+ writable: true,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ "DIV#non-scrollable": {
+ "overflow-y": "visible",
+ overflow: "visible",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(document.scrollingElement);
+ });
+
+ it("should return null when document.scrollingElement is null", () => {
+ Object.defineProperty(document, "scrollingElement", {
+ value: null,
+ writable: true,
+ configurable: true,
+ });
+
+ const nonScrollableParent = createMockElement({
+ scrollHeight: 100,
+ clientHeight: 100,
+ });
+
+ const targetElement = createMockElement({
+ parentElement: nonScrollableParent,
+ });
+
+ Object.defineProperty(nonScrollableParent, "parentElement", {
+ value: null,
+ writable: true,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ DIV: {
+ "overflow-y": "visible",
+ overflow: "visible",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(null);
+ });
+ });
+
+ describe("edge cases", () => {
+ it("should handle element with no parent", () => {
+ const orphanElement = createMockElement({
+ parentElement: null,
+ });
+
+ const result = getScrollableAncestor(orphanElement);
+ expect(result).toBe(document.scrollingElement);
+ });
+
+ it("should stop at document.documentElement", () => {
+ const documentElement = document.createElement("html");
+ Object.defineProperty(document, "documentElement", {
+ value: documentElement,
+ writable: true,
+ configurable: true,
+ });
+
+ const parentElement = createMockElement({
+ scrollHeight: 200,
+ clientHeight: 100,
+ parentElement: documentElement,
+ });
+ parentElement.id = "parent-of-doc-element";
+
+ const targetElement = createMockElement({
+ parentElement: parentElement,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ "DIV#parent-of-doc-element": {
+ "overflow-y": "visible",
+ overflow: "visible",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(document.scrollingElement);
+ });
+
+ it("should handle various overflow values correctly", () => {
+ const tests = [
+ { overflowY: "hidden", overflow: "visible", shouldFind: false },
+ { overflowY: "visible", overflow: "hidden", shouldFind: false },
+ { overflowY: "auto", overflow: "hidden", shouldFind: true },
+ { overflowY: "scroll", overflow: "hidden", shouldFind: true },
+ { overflowY: "hidden", overflow: "auto", shouldFind: true },
+ { overflowY: "hidden", overflow: "scroll", shouldFind: true },
+ ];
+
+ tests.forEach(({ overflowY, overflow, shouldFind }, index) => {
+ const scrollableParent = createMockElement({
+ scrollHeight: 200,
+ clientHeight: 100,
+ });
+ scrollableParent.id = `test-parent-${index}`;
+
+ const targetElement = createMockElement({
+ parentElement: scrollableParent,
+ });
+
+ Object.defineProperty(scrollableParent, "parentElement", {
+ value: null,
+ writable: true,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ [`DIV#test-parent-${index}`]: {
+ "overflow-y": overflowY,
+ overflow: overflow,
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+
+ if (shouldFind) {
+ expect(result).toBe(scrollableParent);
+ } else {
+ expect(result).toBe(document.scrollingElement);
+ }
+ });
+ });
+ });
+
+ describe("complex DOM trees", () => {
+ it("should traverse deep DOM trees correctly", () => {
+ // Create a deep nesting: target -> parent1 -> parent2 -> parent3 -> scrollableParent
+ const scrollableParent = createMockElement({
+ scrollHeight: 400,
+ clientHeight: 200,
+ });
+ scrollableParent.id = "scrollable-root";
+
+ const parent3 = createMockElement({
+ scrollHeight: 100,
+ clientHeight: 100,
+ parentElement: scrollableParent,
+ });
+
+ const parent2 = createMockElement({
+ scrollHeight: 100,
+ clientHeight: 100,
+ parentElement: parent3,
+ });
+
+ const parent1 = createMockElement({
+ scrollHeight: 100,
+ clientHeight: 100,
+ parentElement: parent2,
+ });
+
+ const targetElement = createMockElement({
+ parentElement: parent1,
+ });
+
+ Object.defineProperty(scrollableParent, "parentElement", {
+ value: null,
+ writable: true,
+ });
+
+ window.getComputedStyle = mockGetComputedStyle({
+ "DIV#scrollable-root": {
+ "overflow-y": "auto",
+ overflow: "visible",
+ },
+ DIV: {
+ "overflow-y": "visible",
+ overflow: "visible",
+ },
+ });
+
+ const result = getScrollableAncestor(targetElement);
+ expect(result).toBe(scrollableParent);
+ });
+ });
+});
diff --git a/packages/embeds/embed-core/src/lib/domUtils.ts b/packages/embeds/embed-core/src/lib/domUtils.ts
new file mode 100644
index 0000000000..b95f6cb7e1
--- /dev/null
+++ b/packages/embeds/embed-core/src/lib/domUtils.ts
@@ -0,0 +1,29 @@
+export function getScrollableAncestor(element: Element): Element | null {
+ // Start from parent because we are looking for ancestors of the element.
+ let currentElement: HTMLElement | null = element.parentElement;
+
+ // Walk up the DOM tree to find the first scrollable(across y-axis) ancestor
+ while (currentElement && currentElement !== document.documentElement) {
+ const computedStyle = window.getComputedStyle(currentElement);
+ const overflowY = computedStyle.getPropertyValue("overflow-y");
+ const overflow = computedStyle.getPropertyValue("overflow");
+
+ // Check if element is scrollable
+ const isScrollable = ["auto", "scroll"].includes(overflowY) || ["auto", "scroll"].includes(overflow);
+
+ // Check if element actually has scrollable content
+ const hasScrollableContent = currentElement.scrollHeight > currentElement.clientHeight;
+
+ if (isScrollable && hasScrollableContent) {
+ return currentElement;
+ }
+
+ currentElement = currentElement.parentElement;
+ }
+
+ if (!document.scrollingElement) {
+ return null;
+ }
+
+ return document.scrollingElement;
+}
diff --git a/packages/embeds/embed-core/src/lib/eventHandlers/scrollByDistanceEventHandler.ts b/packages/embeds/embed-core/src/lib/eventHandlers/scrollByDistanceEventHandler.ts
new file mode 100644
index 0000000000..70c18be1a4
--- /dev/null
+++ b/packages/embeds/embed-core/src/lib/eventHandlers/scrollByDistanceEventHandler.ts
@@ -0,0 +1,15 @@
+import type { EmbedEvent } from "../../sdk-action-manager";
+
+export const getScrollByDistanceHandler =
+ (cal: { inlineEl?: Element; scrollByDistance: (distance: number) => void }) =>
+ (e: EmbedEvent<"__scrollByDistance">) => {
+ if (!cal.inlineEl) {
+ // Except inline, others use modalbox which has scroll on iframe itself and thus an attempt to scroll by the child would already be successful
+ console.warn("scrollBy event received but ignored as it isn't an inline embed");
+ return;
+ }
+ const distanceRelativeToIframe = e.detail.data.distance;
+ // Note: In case of inline embed, Iframe's height is always up-to-date to ensure that there is no vertical scrollbar in the iframe.
+ // So, we could just scroll by the distance relative to the iframe and that should bring the content in the iframe into view.
+ cal.scrollByDistance(distanceRelativeToIframe);
+ };
diff --git a/packages/embeds/embed-core/src/utils.ts b/packages/embeds/embed-core/src/lib/utils.ts
similarity index 99%
rename from packages/embeds/embed-core/src/utils.ts
rename to packages/embeds/embed-core/src/lib/utils.ts
index b26c2ed42f..5405fb2433 100644
--- a/packages/embeds/embed-core/src/utils.ts
+++ b/packages/embeds/embed-core/src/lib/utils.ts
@@ -1,4 +1,4 @@
-import type { KnownConfig, PrefillAndIframeAttrsConfig } from "./types";
+import type { KnownConfig, PrefillAndIframeAttrsConfig } from "../types";
export const getErrorString = ({
errorCode,
diff --git a/packages/embeds/embed-core/src/sdk-action-manager.ts b/packages/embeds/embed-core/src/sdk-action-manager.ts
index 0add6c4ee9..151e58861d 100644
--- a/packages/embeds/embed-core/src/sdk-action-manager.ts
+++ b/packages/embeds/embed-core/src/sdk-action-manager.ts
@@ -111,6 +111,12 @@ export type EventDataMap = {
iframeWidth: number;
isFirstTime: boolean;
};
+ __scrollByDistance: {
+ /**
+ * Distance in pixels to scroll by.
+ */
+ distance: number;
+ };
};
export type EventData = {
diff --git a/packages/eslint-plugin/src/rules/no-scroll-into-view-embed.ts b/packages/eslint-plugin/src/rules/no-scroll-into-view-embed.ts
index 453ea7bc5f..3f4bb5fc00 100644
--- a/packages/eslint-plugin/src/rules/no-scroll-into-view-embed.ts
+++ b/packages/eslint-plugin/src/rules/no-scroll-into-view-embed.ts
@@ -7,12 +7,12 @@ export default createRule({
name: "no-scroll-into-view-embed",
meta: {
docs: {
- description: "Disallow usage of scrollIntoView in embed mode",
+ description: "Disallow usage of scrollIntoView and scrollIntoViewSmooth in embed mode",
recommended: "error",
},
messages: {
noScrollIntoViewForEmbed:
- "Make sure to call scrollIntoView conditionally if it is called without user action. Use useIsEmbed() to detect if embed mode and then don't call it for embed case.",
+ "Make sure to call scrollIntoView/scrollIntoViewSmooth conditionally if it is called without user action. Use useIsEmbed() to detect if embed mode and then don't call it for embed case.",
},
type: "problem",
schema: [],
@@ -24,13 +24,19 @@ export default createRule({
const { callee } = node;
if (callee.type === "MemberExpression") {
- if (callee.property.type === "Identifier" && callee.property.name === "scrollIntoView") {
+ if (
+ callee.property.type === "Identifier" &&
+ (callee.property.name === "scrollIntoView" || callee.property.name === "scrollIntoViewSmooth")
+ ) {
context.report({
node,
messageId: "noScrollIntoViewForEmbed",
});
}
- } else if (callee.type === "Identifier" && callee.name === "scrollIntoView") {
+ } else if (
+ callee.type === "Identifier" &&
+ (callee.name === "scrollIntoView" || callee.name === "scrollIntoViewSmooth")
+ ) {
context.report({
node,
messageId: "noScrollIntoViewForEmbed",
diff --git a/packages/features/bookings/Booker/Booker.tsx b/packages/features/bookings/Booker/Booker.tsx
index e0e64e72d1..57580fa757 100644
--- a/packages/features/bookings/Booker/Booker.tsx
+++ b/packages/features/bookings/Booker/Booker.tsx
@@ -13,6 +13,7 @@ import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules/lib/use-schedule/useNonEmptyScheduleDays";
+import { scrollIntoViewSmooth } from "@calcom/lib/browser/browser.utils";
import { PUBLIC_INVALIDATE_AVAILABLE_SLOTS_ON_BOOKING_FORM } from "@calcom/lib/constants";
import { CLOUDFLARE_SITE_ID, CLOUDFLARE_USE_TURNSTILE_IN_BOOKER } from "@calcom/lib/constants";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
@@ -154,10 +155,11 @@ const BookerComponent = ({
} = calendars;
const scrolledToTimeslotsOnce = useRef(false);
+
const scrollToTimeSlots = () => {
- if (isMobile && !scrolledToTimeslotsOnce.current) {
+ if (isMobile && !scrolledToTimeslotsOnce.current && timeslotsRef.current) {
// eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed -- We are allowing it here because scrollToTimeSlots is called on explicit user action where it makes sense to scroll, remember that the goal is to not do auto-scroll on embed load because that ends up scrolling the embedding webpage too
- timeslotsRef.current?.scrollIntoView({ behavior: "smooth" });
+ scrollIntoViewSmooth(timeslotsRef.current, isEmbed);
scrolledToTimeslotsOnce.current = true;
}
};
diff --git a/packages/lib/browser/browser.utils.ts b/packages/lib/browser/browser.utils.ts
index d64a31a7e2..aa613699a1 100644
--- a/packages/lib/browser/browser.utils.ts
+++ b/packages/lib/browser/browser.utils.ts
@@ -1,3 +1,5 @@
+import { sdkActionManager } from "@calcom/embed-core/embed-iframe";
+
type BrowserInfo = {
url: string;
path: string;
@@ -20,3 +22,57 @@ export const getBrowserInfo = (): Partial => {
origin: window.document.location?.origin,
};
};
+
+export const isSafariBrowser = (): boolean => {
+ if (typeof window === "undefined") return false;
+ const ua = navigator.userAgent.toLowerCase();
+ return ua.includes("safari") && !ua.includes("chrome");
+};
+
+/**
+ * Asks parent to scroll to an element inside iframe.
+ * This is a workaround for Safari iframe scrolling behavior and so isn't recommended to be used by external consumers.
+ */
+const askParentToScrollToElement = (element: HTMLElement): void => {
+ const elementRect = element.getBoundingClientRect();
+ const elementTop = elementRect.top;
+ sdkActionManager?.fire("__scrollByDistance", {
+ distance: elementTop,
+ });
+};
+
+const afterNthPaintCycle = (n: number, callback: () => void): void => {
+ requestAnimationFrame(() => {
+ if (n === 1) {
+ callback();
+ return;
+ }
+ requestAnimationFrame(() => afterNthPaintCycle(n - 1, callback));
+ });
+};
+
+/**
+ * Cross-browser compatible scrollIntoView with Safari iframe support
+ */
+export const scrollIntoViewSmooth = (element: HTMLElement, isEmbed = false): void => {
+ const currentPosition = element.getBoundingClientRect().top;
+ // eslint-disable-next-line @calcom/eslint/no-scroll-into-view-embed
+ element.scrollIntoView({ behavior: "smooth" });
+ if (!isEmbed) {
+ return;
+ }
+ const mightNeedSafariWorkaround = isSafariBrowser();
+
+ // First paint cycle will actually start scrolling the element.
+ // So, we need to wait for the second paint cycle to guarantee that the element is scrolled some amount.
+ afterNthPaintCycle(2, () => {
+ const newPosition = element.getBoundingClientRect().top;
+ const didScroll = currentPosition !== newPosition;
+ const scrollWorkaroundNeeded = mightNeedSafariWorkaround && !didScroll;
+ if (!scrollWorkaroundNeeded) {
+ return;
+ }
+
+ askParentToScrollToElement(element);
+ });
+};