From 4876020de6f99ddff6f46b83deec58113fa43e71 Mon Sep 17 00:00:00 2001 From: Hariom Date: Wed, 16 Jul 2025 14:37:49 +0530 Subject: [PATCH 1/2] Add playground --- packages/embeds/embed-core/index.html | 11 +++++- .../embed-core/playground/lib/playground.ts | 36 ++++++++++++++++++- 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index 0183dedab85c6a..0abff3d98909b7 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -399,7 +399,16 @@

On the right side you can book a team meeting =>

-

Hide EventType Details Test

+

Hide EventType Details Test(Also tests window scroll to timeslots when date is selected)

+
+
+ +
+

Scroll to Timeslot Test - Selecting a date would scroll the window to the timeslot

+
+
+
+

Container Scroll Test - Selecting a date would scroll the element to the timeslot

diff --git a/packages/embeds/embed-core/playground/lib/playground.ts b/packages/embeds/embed-core/playground/lib/playground.ts index f243a696506068..a468791aec2e3e 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 From 1c76635fc63f919facec86b702abe4d3bafc5c28 Mon Sep 17 00:00:00 2001 From: Hariom Date: Wed, 16 Jul 2025 14:38:07 +0530 Subject: [PATCH 2/2] Fix scroll issue on safari --- packages/embeds/embed-core/index.html | 2 +- .../embeds/embed-core/src/Inline/inline.ts | 2 +- .../embed-core/src/ModalBox/ModalBox.ts | 2 +- .../embed-core/src/__tests__/utils.test.ts | 2 +- .../src/embed-iframe/lib/embedStore.ts | 2 +- packages/embeds/embed-core/src/embed.ts | 30 +- .../embed-core/src/lib/domUtils.test.ts | 463 ++++++++++++++++++ .../embeds/embed-core/src/lib/domUtils.ts | 29 ++ .../scrollByDistanceEventHandler.ts | 15 + .../embeds/embed-core/src/{ => lib}/utils.ts | 2 +- .../embed-core/src/sdk-action-manager.ts | 6 + .../src/rules/no-scroll-into-view-embed.ts | 14 +- packages/features/bookings/Booker/Booker.tsx | 6 +- packages/lib/browser/browser.utils.ts | 56 +++ 14 files changed, 613 insertions(+), 18 deletions(-) create mode 100644 packages/embeds/embed-core/src/lib/domUtils.test.ts create mode 100644 packages/embeds/embed-core/src/lib/domUtils.ts create mode 100644 packages/embeds/embed-core/src/lib/eventHandlers/scrollByDistanceEventHandler.ts rename packages/embeds/embed-core/src/{ => lib}/utils.ts (99%) diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index 0abff3d98909b7..abe160bc67be7a 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -399,7 +399,7 @@

On the right side you can book a team meeting =>

-

Hide EventType Details Test(Also tests window scroll to timeslots when date is selected)

+

Hide EventType Details Test

diff --git a/packages/embeds/embed-core/src/Inline/inline.ts b/packages/embeds/embed-core/src/Inline/inline.ts index 15a1c3481b4eab..aef52ef5b42af7 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 2ac751a5a39bad..901cd22541e4e9 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 7ad74c79dcb727..a54180f8521080 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 003db7caf4ceb5..ab16c24dfdf44f 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 86b68bf1cd98a6..88e6e7678940e7 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 00000000000000..c8113594fdc7b8 --- /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 00000000000000..b95f6cb7e1256f --- /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 00000000000000..70c18be1a44134 --- /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 b26c2ed42f7601..5405fb2433426a 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 0add6c4ee927c4..151e58861d7a07 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 453ea7bc5f06bf..3f4bb5fc00d971 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 e0e64e72d1372f..57580fa757bdb6 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 d64a31a7e29f12..aa613699a13801 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); + }); +};