fix: Scrolling to timeslot issue on Safari with inline-embeds (#22547)
* Add playground * Fix scroll issue on safari
This commit is contained in:
@@ -399,7 +399,16 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="inline-embed-container" id="cal-booking-place-hideEventTypeDetails">
|
||||
<h3><a href="?only=hideEventTypeDetails">Hide EventType Details Test</a></h3>
|
||||
<h3><a href="?only=ns:hideEventTypeDetails">Hide EventType Details Test</a></h3>
|
||||
<div class="place"></div>
|
||||
</div>
|
||||
|
||||
<div class="inline-embed-container" style="width: 500px; overflow-y: scroll;" id="cal-booking-place-windowScrollToTimeslot">
|
||||
<h3><a href="?only=ns:windowScrollToTimeslot">Scroll to Timeslot Test - Selecting a date would scroll the window to the timeslot</a></h3>
|
||||
<div class="place"></div>
|
||||
</div>
|
||||
<div class="inline-embed-container" style="height: 500px; width: 500px; overflow-y: scroll;" id="cal-booking-place-containerScrollToTimeslot">
|
||||
<h3><a href="?only=ns:containerScrollToTimeslot">Container Scroll Test - Selecting a date would scroll the element to the timeslot</a></h3>
|
||||
<div class="place"></div>
|
||||
</div>
|
||||
<div class="inline-embed-container" id="cal-booking-place-conflicting-theme">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(Object.entries(params).filter(([key, value]) => !excludeParam(key, value)));
|
||||
}
|
||||
|
||||
@@ -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<string, Record<string, string>> = {}) {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
import type { KnownConfig, PrefillAndIframeAttrsConfig } from "./types";
|
||||
import type { KnownConfig, PrefillAndIframeAttrsConfig } from "../types";
|
||||
|
||||
export const getErrorString = ({
|
||||
errorCode,
|
||||
@@ -111,6 +111,12 @@ export type EventDataMap = {
|
||||
iframeWidth: number;
|
||||
isFirstTime: boolean;
|
||||
};
|
||||
__scrollByDistance: {
|
||||
/**
|
||||
* Distance in pixels to scroll by.
|
||||
*/
|
||||
distance: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type EventData<T extends keyof EventDataMap> = {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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<BrowserInfo> => {
|
||||
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);
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user