diff --git a/packages/embeds/README.md b/packages/embeds/README.md index 5af25a4846..dff42b8e5b 100644 --- a/packages/embeds/README.md +++ b/packages/embeds/README.md @@ -15,3 +15,25 @@ To publish the packages. Following steps should be followed. All commands are to 2. `yarn changeset version` -> Bumps the versions as required 3. Get the PR reviewed and merged 4. `yarn publish-embed` -> Releases all packages. We can't use `yarn changeset publish` because it doesn't support workspace: prefix removal yet. See https://github.com/changesets/changesets/issues/432#issuecomment-1016365428 + +## Skeleton Loader + +Skeleton loader is shown for supported page types. For all other page types, default non-skeleton loader is shown. +Status: +- Layout + - [x] Responsive + - [x] Mobile Layout + - [x] month_view Layout + - [ ] week_view Layout + - [ ] column_view Layout +- Theming + - [x] Dark and Light theme + NOTE: _If user has preference for theme configured within app, that has to be communicated clearly in the embed too for skeleton to work_ + - [x] Change in system theme should reflect without page refresh +- Page Types supported + - [x] user.event.booking.slots + - [x] team.event.booking.slots + - [x] [Partially supported] user.event.booking.form - Shows skeleton but of the slots page + - [x] [Partially supported] team.event.booking.form - Shows skeleton but of the slots page + - [ ] user.profile + - [ ] team.profile diff --git a/packages/embeds/embed-core/index.html b/packages/embeds/embed-core/index.html index 4d02bd7072..5ea011a562 100644 --- a/packages/embeds/embed-core/index.html +++ b/packages/embeds/embed-core/index.html @@ -4,8 +4,13 @@ @@ -93,7 +98,7 @@ function doubleInstallSnippetWithInlineEmbedWithNonDefaultNamespace(selector) { Cal("init", "doubleInstall", { debug: true, - calOrigin: "http://localhost:3000", + calOrigin: window.calOrigin, }); Cal.ns.doubleInstall("inline", { elementOrSelector: selector, @@ -105,7 +110,7 @@ embedSnippet(); Cal("init", "doubleInstall", { debug: true, - calOrigin: "http://localhost:3000", + calOrigin: window.calOrigin, }); Cal.ns.doubleInstall("inline", { elementOrSelector: selector, @@ -120,7 +125,7 @@ function doubleInstallSnippetWithInlineEmbed(selector) { Cal("init", { debug: true, - calOrigin: "http://localhost:3000", + calOrigin: window.calOrigin, }); Cal("inline", { elementOrSelector: selector, @@ -132,7 +137,7 @@ embedSnippet(); Cal("init", { debug: true, - calOrigin: "http://localhost:3000", + calOrigin: window.calOrigin, }); Cal("inline", { elementOrSelector: selector, @@ -146,7 +151,7 @@ function addTwoInlineEmbedWithTwoDifferentNamespacesWithDifferentInitConfig() { Cal("init", "namespace-init-test-1", { debug: true, - calOrigin: "http://localhost:3000", + calOrigin: window.calOrigin, }); Cal("init", "namespace-init-test-2", { debug: true, @@ -168,7 +173,7 @@ }); } - + -
- -
-
-
- -
-
-
- -
-
-
- -
-
-
+
+
+ +
-
- With Dark Color Scheme for the Page - Non responsive version of this page here - - - Page Params Forwarding(Prefills various fields by auto-forwarding query params) - - - Go to Prerender test page only + +
+
+
+ +
+
+
+ +
+
+
+
+
+ With Dark Color Scheme for the Page + Non responsive version of this page here + + + Page Params Forwarding(Prefills various fields by auto-forwarding query params) + + + Go to Prerender test page only + Go to Preload test page only - Go to Preload test page only - - -
- -
- Render All embeds together - It would be slow to load -
- Floating Popup + + +
+ +
+ Render All embeds together - It would be slow to load +
+ Floating Popup -

Popup Examples - Theme Tests

- - - - - - - -

Popup Examples - Reschedule Tests

- - -

Popup Examples - Different Configurations

- - - -

Popup Examples - Routing Form Tests

- - - -

Popup Examples - Child Element Target Tests

- - I am Anchor - [I am Bold inside anchor] - - I am span inside anchor - - - -
+

Popup Examples - Theme Tests

+ + + + + + + +

Popup Examples - Reschedule Tests

+ + +

Popup Examples - Different Configurations

+ + + +

Popup Examples - Routing Form Tests

+ + + +

Popup Examples - Child Element Target Tests

+ + I am Anchor + [I am Bold inside anchor] + + I am span inside anchor + + + +

Inline Embed Examples

@@ -425,7 +431,6 @@
Note that one of the embeds would stay in loading state as they are using the same namespace and it is not supported to have more than 1 embeds using same namespace
-

Test Month View

@@ -451,6 +456,29 @@

Test Auto Scroll

+ +
+

Skeleton Loader INLINE Demo

+
+
+
+
+
+
+
+ Skeleton Loader MODAL Demo +

+
+
+
+
+
+
+ diff --git a/packages/embeds/embed-core/package.json b/packages/embeds/embed-core/package.json index 6c79e84414..ae26f49223 100644 --- a/packages/embeds/embed-core/package.json +++ b/packages/embeds/embed-core/package.json @@ -22,6 +22,8 @@ "buildWatchAndServer": "run-p '__dev' 'vite --port 3100 --strict-port --host --open'", "buildWatchAndServer-https": "run-p '__dev' 'vite --port 3100 --strict-port --host --open --https'", "dev": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer'", + "buildWatchAndServer-no-auto-open": "run-p '__dev' 'vite --port 3100 --strict-port --host'", + "dev-no-open": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer-no-auto-open'", "dev-https": "yarn tailwind && run-p 'tailwind --watch' 'buildWatchAndServer-https'", "dev-real": "vite dev --port 3100", "type-check": "tsc --pretty --noEmit", diff --git a/packages/embeds/embed-core/playground.ts b/packages/embeds/embed-core/playground.ts index bfdb565c1c..da5692acda 100644 --- a/packages/embeds/embed-core/playground.ts +++ b/packages/embeds/embed-core/playground.ts @@ -10,7 +10,8 @@ const callback = function (e: any) { console.log("Event: ", e.type, detail); }; -const origin = `${new URL(document.URL).protocol}localhost:3000`; +// @ts-expect-error window.calOrigin is set in index.html +const origin = `${new URL(document.URL).protocol}//${window.calOrigin.split("//")[1]}`; document.addEventListener("click", (e) => { const target = e.target as HTMLElement; if ("href" in target && typeof target.href === "string") { @@ -629,3 +630,78 @@ Cal("on", { action: "bookingSuccessfulV2", callback: bookingSuccessfulV2Callback, }); + +if (only === "all" || only === "ns:skeletonDemo") { + Cal("init", "skeletonDemo", { + debug: true, + origin: origin, + }); + + // Example showing booking page skeleton + Cal.ns.skeletonDemo("inline", { + elementOrSelector: "#cal-booking-place-skeletonDemo .place", + calLink: "pro/30min", + config: { + theme: "auto", + iframeAttrs: { + id: "cal-booking-place-skeletonDemo-iframe", + }, + "flag.coep": "true", + "cal.embed.pageType": "user.event.booking.slots", + }, + }); + + Cal.ns.skeletonDemo("on", { + action: "*", + callback, + }); +} + +if (only === "all" || only === "ns:skeletonDemoElementClick") { + Cal("init", "skeletonDemoElementClick", { + debug: true, + origin: origin, + }); + + Cal.ns.skeletonDemoElementClick("on", { + action: "*", + callback, + }); +} + +// 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 + // Also, even if we need to scroll to the same iframe, we need to still reset it otherwise hashchange event will not fire + location.hash = ""; + let api: typeof Cal | (typeof Cal)["ns"][string] | null = null; + + const getNamespace = () => { + const url = new URL(document.URL); + const only = url.searchParams.get("only") || ""; + return only !== "all" ? only.replace("ns:", "") : null; + }; + + const updateApiOnNamespaceChange = () => { + const namespace = getNamespace(); + api = namespace && namespace !== "default" ? Cal.ns[namespace] : Cal; + api?.("on", { + action: "*", + callback: (e) => { + function scrollToIframeInPlayground() { + const namespace = getNamespace(); + if (namespace) { + location.hash = `#cal-booking-place-${namespace}-iframe`; + } + } + scrollToIframeInPlayground(); + }, + }); + }; + + // Initial namespace setup + updateApiOnNamespaceChange(); + + // Update namespace on hash change + window.addEventListener("hashchange", updateApiOnNamespaceChange); +})(); diff --git a/packages/embeds/embed-core/src/EmbedElement.test.ts b/packages/embeds/embed-core/src/EmbedElement.test.ts new file mode 100644 index 0000000000..86f7616646 --- /dev/null +++ b/packages/embeds/embed-core/src/EmbedElement.test.ts @@ -0,0 +1,361 @@ +import { fakeDeviceMatchesMediaQuery } from "../test/__mocks__/windowMatchMedia"; + +import type { AllPossibleLayouts, EmbedPageType } from "src/types"; +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import type { Mock } from "vitest"; + +import { EmbedElement } from "./EmbedElement"; +import inlineHTML from "./Inline/inlineHtml"; +import { getColorSchemeDarkQuery } from "./ui-utils"; + +(function defineEmbedTestElement() { + class TestEmbedElement extends EmbedElement { + constructor({ + isModal, + getSkeletonData, + dataset, + }: { + isModal?: boolean; + getSkeletonData: Mock; + dataset: Record; + }) { + super({ + isModal: isModal ?? false, + getSkeletonData, + // @ts-expect-error - Integration test mode + dataset, + }); + } + } + customElements.define("test-embed", TestEmbedElement); +})(); + +function buildMediaQueryListEvent({ type, matches }: { type: string; matches: boolean }) { + return { + type, + matches, + } as unknown as MediaQueryListEvent; +} + +function mockGetSkeletonData() { + return vi + .fn() + .mockImplementation( + ({ layout, pageType }: { layout: AllPossibleLayouts; pageType: EmbedPageType | null }) => { + return { + skeletonContent: `
Skeleton Content
`, + skeletonContainerStyle: "height: 500px", + skeletonStyle: "display: block", + }; + } + ); +} + +function createTestEmbedElement(data: { + dataset?: { pageType?: string; theme?: string; layout?: AllPossibleLayouts }; + getSkeletonData?: Mock; + isModal?: boolean; +}) { + const { dataset, getSkeletonData = mockGetSkeletonData() } = data; + const element = new (customElements.get("test-embed")!)({ + isModal: data.isModal ?? false, + getSkeletonData, + dataset: dataset || {}, + }) as EmbedElement; + + // Mock shadow root + const shadowRoot = { + querySelector: element.querySelector.bind(element), + host: element, + }; + + Object.defineProperty(element, "shadowRoot", { + value: shadowRoot, + writable: true, + }); + + element.innerHTML = inlineHTML({ + layout: dataset?.layout, + pageType: dataset?.pageType as EmbedPageType | null, + }); + + document.body.appendChild(element); + + return element; +} + +function expectSkeletonLoader(element: EmbedElement) { + const skeletonEl = element.getSkeletonElement(); + const loaderEl = element.getLoaderElement(); + + expect(skeletonEl.style.display).toBe("block"); + expect(loaderEl.style.display).toBe("none"); +} + +function expectDefaultLoader(element: EmbedElement) { + const skeletonEl = element.getSkeletonElement(); + const loaderEl = element.getLoaderElement(); + + expect(skeletonEl.style.display).toBe("none"); + expect(loaderEl.style.display).toBe("block"); +} + +function expectLoaderToBeHidden(element: EmbedElement) { + const skeletonEl = element.getSkeletonElement(); + const loaderEl = element.getLoaderElement(); + + expect(skeletonEl.style.display).toBe("none"); + expect(loaderEl.style.display).toBe("none"); +} + +function expectLayoutToBe(layout: AllPossibleLayouts, element: EmbedElement) { + expect(element.layout).toBe(layout); + const skeletonEl = element.getSkeletonElement(); + expect(skeletonEl.querySelector(`${layout}-layout`)).toBeDefined(); +} + +function mockGetComputedStyle() { + // getComputedStyle isn't properly implemented in JSDOM and doesn't return height property + window.getComputedStyle = vi.fn().mockImplementation((el: HTMLElement) => { + if (el.style.display === "none") { + return { + height: "0px", + }; + } else { + return { + height: "1010px", + }; + } + }); +} + +describe("EmbedElement", () => { + let element: EmbedElement; + let mockGetSkeletonData: Mock; + + beforeEach(() => { + // Register the custom element + mockGetComputedStyle(); + }); + + afterEach(() => { + if (!element) { + throw new Error("`element` not defined"); + } + document.body.removeChild(element); + vi.restoreAllMocks(); + }); + + describe("Loader Behavior", () => { + describe("Non-Modal Mode", () => { + it("should show skeleton loader for supported page types on creation", () => { + element = createTestEmbedElement({ + dataset: { + pageType: "user.event.booking.slots", + }, + }); + + expectSkeletonLoader(element); + }); + + it("should show default loader for when page type is not provided", () => { + element = createTestEmbedElement({ + dataset: {}, + }); + + expectDefaultLoader(element); + }); + + it("should show default loader for when page type is not supported", () => { + element = createTestEmbedElement({ + dataset: { + pageType: "unknown", + }, + }); + + expectDefaultLoader(element); + }); + + it("should hide skeleton loader when toggled off", () => { + element = createTestEmbedElement({ + dataset: { + pageType: "user.event.booking.slots", + }, + }); + + expectSkeletonLoader(element); + + element.toggleLoader(false); + expectLoaderToBeHidden(element); + }); + + it("should hide default loader when toggled off", () => { + element = createTestEmbedElement({}); + + expectDefaultLoader(element); + + element.toggleLoader(false); + expectLoaderToBeHidden(element); + }); + + it("should update container height correctly", () => { + element = createTestEmbedElement({ + dataset: { + pageType: "user.event.booking.slots", + }, + }); + const skeletonContainerEl = element.getSkeletonContainerElement(); + + // Initial height should be set based on skeleton height + expect(skeletonContainerEl.style.height).not.toBe(""); + + element.toggleLoader(false); + expect(skeletonContainerEl.style.height).toBe(""); + expect(skeletonContainerEl.style.display).toBe("none"); + }); + }); + + describe("Modal Mode", () => { + let isModal: boolean; + beforeEach(() => { + isModal = true; + }); + + it("should show default loader for unsupported page types", () => { + element = createTestEmbedElement({ + isModal, + }); + delete element.dataset.pageType; + + expectDefaultLoader(element); + }); + + it("should show skeleton loader for supported page types", () => { + element = createTestEmbedElement({ + isModal, + dataset: { + pageType: "user.event.booking.slots", + }, + }); + + expectSkeletonLoader(element); + }); + + it("should adjust skeletonContainer height correctly", () => { + element = createTestEmbedElement({ + isModal, + dataset: { + pageType: "user.event.booking.slots", + }, + }); + const skeletonContainerEl = element.getSkeletonContainerElement(); + + // Modal should have maxHeight set + expect(skeletonContainerEl.style.maxHeight).not.toBe(""); + expect(skeletonContainerEl.style.height).not.toBe(""); + + element.toggleLoader(false); + expect(skeletonContainerEl.style.height).toBe(""); + expect(skeletonContainerEl.style.display).toBe(""); + expect(skeletonContainerEl.style.maxHeight).toBe(""); + }); + }); + + describe("Theme Handling", () => { + it("should apply theme class correctly", () => { + element = createTestEmbedElement({ + dataset: { + theme: "dark", + }, + getSkeletonData: vi.fn(), + }); + expect(element.classList.contains("dark")).toBe(true); + expect(element.classList.contains("light")).toBe(false); + }); + + it("should update theme class when system preference changes as long as embed theme is not set", () => { + element = createTestEmbedElement({ + getSkeletonData: vi.fn(), + }); + getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true })); + + expect(element.classList.contains("dark")).toBe(true); + expect(element.classList.contains("light")).toBe(false); + }); + + it("should not update theme on system color scheme change when embed theme is set", () => { + element = createTestEmbedElement({ + dataset: { + theme: "light", + }, + getSkeletonData: vi.fn(), + }); + getColorSchemeDarkQuery().dispatchEvent(buildMediaQueryListEvent({ type: "change", matches: true })); + expect(element.classList.contains("light")).toBe(true); + }); + }); + + describe("Layout Handling", () => { + it("should set layout to month_view even if mobile is provided when device matches max-width: >768px", () => { + fakeDeviceMatchesMediaQuery("(max-width: 800px)"); + element = createTestEmbedElement({ + dataset: { layout: "mobile" }, + }); + + expectLayoutToBe("month_view", element); + }); + + it("should set layout to mobile even if month_view is provided when device matches max-width: 768px", () => { + fakeDeviceMatchesMediaQuery("(max-width: 768px)"); + element = createTestEmbedElement({ + dataset: { layout: "month_view" }, + }); + + expectLayoutToBe("mobile", element); + }); + + it("should set layout to month_view even if mobile is provided when device matches max-width: >768px", () => { + fakeDeviceMatchesMediaQuery("(max-width: 800px)"); + element = createTestEmbedElement({ + dataset: { layout: "mobile" }, + }); + + expectLayoutToBe("month_view", element); + }); + + it("should set layout from month_view to mobile when device changes its max-width from >768px to <=768px", () => { + fakeDeviceMatchesMediaQuery("(max-width: 800px)"); + element = createTestEmbedElement({ + dataset: { layout: "month_view" }, + }); + + expectLayoutToBe("month_view", element); + + fakeDeviceMatchesMediaQuery("(max-width: 768px)"); + // It hasn't changed because resize didn't happen + expectLayoutToBe("month_view", element); + + // Now resize the window + window.dispatchEvent(new Event("resize")); + expectLayoutToBe("mobile", element); + }); + + it("should set layout from mobile to month_view when device changes its max-width from <=768px to >768px", () => { + fakeDeviceMatchesMediaQuery("(max-width: 768px)"); + element = createTestEmbedElement({ + dataset: { layout: "mobile" }, + }); + + expectLayoutToBe("mobile", element); + + fakeDeviceMatchesMediaQuery("(max-width: 800px)"); + // It hasn't changed because resize didn't happen + expectLayoutToBe("mobile", element); + + // Now resize the window + window.dispatchEvent(new Event("resize")); + expectLayoutToBe("month_view", element); + }); + }); + }); +}); diff --git a/packages/embeds/embed-core/src/EmbedElement.ts b/packages/embeds/embed-core/src/EmbedElement.ts new file mode 100644 index 0000000000..250f738f07 --- /dev/null +++ b/packages/embeds/embed-core/src/EmbedElement.ts @@ -0,0 +1,218 @@ +import type { EmbedThemeConfig, AllPossibleLayouts, BookerLayouts, EmbedPageType } from "./types"; +import { + getThemeClassForEmbed, + getTrueLayout, + isThemePreferenceProvided, + removeDarkColorSchemeChangeListener, + addDarkColorSchemeChangeListener, + getMaxHeightForModal, +} from "./ui-utils"; + +type ShadowRootWithStyle = ShadowRoot & { + host: HTMLElement & { style: CSSStyleDeclaration }; +}; + +export class EmbedElement extends HTMLElement { + // Theme is set once by the user + public theme!: EmbedThemeConfig | null; + public isModal!: boolean; + public skeletonContainerHeightTimer: number | null = null; + // Theme Class is derived from `this.theme` as well as system color scheme preference + public themeClass!: string; + public layout!: AllPossibleLayouts; + public getSkeletonData!: (_args: { layout: AllPossibleLayouts; pageType: EmbedPageType | null }) => { + skeletonContent: string; + skeletonContainerStyle: string; + skeletonStyle: string; + }; + + private boundResizeHandler: () => void; + private boundPrefersDarkThemeChangedHandler: (e: MediaQueryListEvent) => void; + private isSkeletonSupportedPageType() { + const pageType = this.getPageType(); + return ( + pageType === "user.event.booking.slots" || + pageType === "team.event.booking.slots" || + pageType === "user.event.booking.form" || + pageType === "team.event.booking.form" + ); + } + public assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRootWithStyle } { + if (!this.shadowRoot) { + throw new Error("No shadow root"); + } + } + + public getPageType(): EmbedPageType { + return this.dataset.pageType as EmbedPageType; + } + public getLayout(): AllPossibleLayouts { + return getTrueLayout({ layout: (this.dataset.layout as BookerLayouts | undefined) ?? null }); + } + + getSkeletonContainerElement(): HTMLElement { + this.assertHasShadowRoot(); + const element = this.shadowRoot.querySelector("#skeleton-container"); + if (!element) { + throw new Error("No skeleton container element"); + } + return element; + } + + getSkeletonElement(): HTMLElement { + this.assertHasShadowRoot(); + const element = this.shadowRoot.querySelector("#skeleton"); + if (!element) { + throw new Error("No skeleton element"); + } + return element; + } + + private ensureContainerTakesSkeletonHeightWhenVisible() { + const skeletonEl = this.getSkeletonElement(); + const skeletonContainerEl = this.getSkeletonContainerElement(); + const isModal = this.isModal; + // skeletonEl is absolute positioned, so, we manually adjust the height of the Skeleton Container, so that the content around it doesn't do layout shift when actual iframe appears in its place + const heightOfSkeleton = parseFloat(getComputedStyle(skeletonEl).height); + if (isModal) { + const skeletonContainerContainingIframeToo = skeletonContainerEl; + if (heightOfSkeleton) { + // maxHeight need to be set only if skeleton inside the container is visible + skeletonContainerContainingIframeToo.style.maxHeight = `${getMaxHeightForModal()}px`; + skeletonContainerContainingIframeToo.style.height = `${heightOfSkeleton}px`; + } else { + // Reset props and be done with requestAnimationFrame + skeletonContainerContainingIframeToo.style.height = ""; + skeletonContainerContainingIframeToo.style.maxHeight = ""; + return; + } + } else { + const skeletonContainerNotContainingIframe = skeletonContainerEl; + const heightOfIframeByDefault = 300; + const diff = heightOfSkeleton - heightOfIframeByDefault; + if (heightOfSkeleton) { + if (diff > 0) { + // Skeleton is positioned overlapping the iframe(hidden with height of 300px already) so we add to container height only the difference + skeletonContainerNotContainingIframe.style.height = `${diff}px`; + } + } else { + // We will be here when skeleton is display:none + // Reset height - and be done with requestAnimationFrame + skeletonContainerNotContainingIframe.style.height = ""; + return; + } + } + const rafId = requestAnimationFrame(this.ensureContainerTakesSkeletonHeightWhenVisible.bind(this)); + this.skeletonContainerHeightTimer = rafId; + return rafId; + } + + getLoaderElement(): HTMLElement { + this.assertHasShadowRoot(); + const loaderEl = this.shadowRoot.querySelector(".loader"); + + if (!loaderEl) { + throw new Error("No loader element"); + } + + return loaderEl; + } + + public toggleLoader(show: boolean) { + const skeletonEl = this.getSkeletonElement(); + + const loaderEl = this.getLoaderElement(); + const skeletonContainerEl = this.getSkeletonContainerElement(); + const supportsSkeleton = this.isSkeletonSupportedPageType(); + + if (!supportsSkeleton) { + loaderEl.style.display = show ? "block" : "none"; + skeletonEl.style.display = "none"; + } else { + skeletonEl.style.display = show ? "block" : "none"; + loaderEl.style.display = "none"; + if (!this.isModal && !show) { + const skeletonContainerNotContainingIframe = skeletonContainerEl; + // In non-modal layout, skeletonContainerEl is static positioned before the slot(i.e. iframe) and it takes space even if its content is hidden. So, we explicitly set display to none + // Also, it doesn't contain anything other than skeleton, so it is safe to set display to none + skeletonContainerNotContainingIframe.style.display = "none"; + } + } + this.ensureContainerTakesSkeletonHeightWhenVisible(); + } + + constructor(data: { + isModal: boolean; + getSkeletonData: (_args: { layout: AllPossibleLayouts; pageType: EmbedPageType | null }) => { + skeletonContent: string; + skeletonContainerStyle: string; + skeletonStyle: string; + }; + }) { + super(); + if (process.env.INTEGRATION_TEST_MODE) { + // @ts-expect-error - Integration test mode + Object.assign(this.dataset, data.dataset); + } + this.isModal = data.isModal; + this.layout = this.getLayout(); + this.theme = this.dataset.theme as EmbedThemeConfig | null; + this.themeClass = getThemeClassForEmbed({ theme: this.theme }); + this.classList.add(this.themeClass); + + this.getSkeletonData = data.getSkeletonData; + this.boundResizeHandler = this.resizeHandler.bind(this); + this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this); + } + + public resizeHandler() { + const newLayout = getTrueLayout({ layout: this.layout ?? null }); + if (newLayout === this.layout) { + return; + } + this.layout = newLayout; + const { skeletonContent, skeletonContainerStyle, skeletonStyle } = this.getSkeletonData({ + layout: this.getLayout(), + pageType: this.getPageType() ?? null, + }); + + const skeletonContainerEl = this.getSkeletonContainerElement(); + const skeletonEl = this.getSkeletonElement(); + + skeletonContainerEl.setAttribute("style", skeletonContainerStyle); + skeletonEl.setAttribute("style", skeletonStyle); + + skeletonEl.innerHTML = skeletonContent; + } + + public prefersDarkThemeChangedHandler(e: MediaQueryListEvent) { + const isDarkPreferred = e.matches; + const allPossibleThemeClasses = ["dark", "light"]; + if (isThemePreferenceProvided(this.theme)) { + // User has provided a theme preference, so we stick to that and don't react to system theme change + return; + } + const newThemeClass = getThemeClassForEmbed({ + theme: isDarkPreferred ? "dark" : "light", + }); + if (newThemeClass !== this.themeClass) { + this.classList.remove(...allPossibleThemeClasses); + this.classList.add(newThemeClass); + } + } + connectedCallback() { + // Make sure to show the loader initially. + // toggleLoader takes care of deciding which loader default/skeleton to be shown + this.toggleLoader(true); + window.addEventListener("resize", this.boundResizeHandler); + addDarkColorSchemeChangeListener(this.boundPrefersDarkThemeChangedHandler); + } + + disconnectedCallback() { + if (this.skeletonContainerHeightTimer) { + cancelAnimationFrame(this.skeletonContainerHeightTimer); + } + window.removeEventListener("resize", this.boundResizeHandler); + removeDarkColorSchemeChangeListener(this.boundPrefersDarkThemeChangedHandler); + } +} diff --git a/packages/embeds/embed-core/src/Inline/inline.ts b/packages/embeds/embed-core/src/Inline/inline.ts index 25fcbcd6bb..883562f028 100644 --- a/packages/embeds/embed-core/src/Inline/inline.ts +++ b/packages/embeds/embed-core/src/Inline/inline.ts @@ -1,31 +1,25 @@ +import { EmbedElement } from "../EmbedElement"; import loaderCss from "../loader.css?inline"; import { getErrorString } from "../utils"; -import inlineHtml from "./inlineHtml"; +import inlineHtml, { getSkeletonData } from "./inlineHtml"; -export class Inline extends HTMLElement { +export class Inline extends EmbedElement { static get observedAttributes() { return ["loading"]; } - assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRoot } { - if (!this.shadowRoot) { - throw new Error("No shadow root"); - } - } - attributeChangedCallback(name: string, oldValue: string, newValue: string) { this.assertHasShadowRoot(); - const loaderEl = this.shadowRoot.querySelector(".loader"); const errorEl = this.shadowRoot.querySelector("#error"); const slotEl = this.shadowRoot.querySelector("slot"); - if (!loaderEl || !slotEl || !errorEl) { + if (!slotEl || !errorEl) { throw new Error("One of loaderEl, slotEl or errorEl is missing"); } if (name === "loading") { if (newValue == "done") { - loaderEl.style.display = "none"; + this.toggleLoader(false); } else if (newValue === "failed") { - loaderEl.style.display = "none"; + this.toggleLoader(false); slotEl.style.visibility = "hidden"; errorEl.style.display = "block"; const errorString = getErrorString(this.dataset.errorCode); @@ -33,10 +27,14 @@ export class Inline extends HTMLElement { } } } + constructor() { - super(); + super({ isModal: false, getSkeletonData }); this.attachShadow({ mode: "open" }); this.assertHasShadowRoot(); - this.shadowRoot.innerHTML = `${inlineHtml}`; + this.shadowRoot.innerHTML = `${inlineHtml({ + layout: this.layout, + pageType: this.getPageType() ?? null, + })}`; } } diff --git a/packages/embeds/embed-core/src/Inline/inlineHtml.ts b/packages/embeds/embed-core/src/Inline/inlineHtml.ts index 715858a262..453f457a37 100644 --- a/packages/embeds/embed-core/src/Inline/inlineHtml.ts +++ b/packages/embeds/embed-core/src/Inline/inlineHtml.ts @@ -1,10 +1,58 @@ -const html = `
-
- +import type { AllPossibleLayouts, EmbedPageType } from "../types"; +import { generateSkeleton } from "../ui/skeleton"; + +const html = ({ + layout = "month_view", + pageType, +}: { + layout?: AllPossibleLayouts; + pageType: EmbedPageType | null; +}) => { + const { skeletonContent, skeletonContainerStyle, skeletonStyle } = getSkeletonData({ + layout, + pageType, + }); + + return ` +
+
+ ${skeletonContent} +
+
+
+ +
+ +
- -
-`; + +`; +}; + +export const getSkeletonData = ({ + layout, + pageType, +}: { + layout: AllPossibleLayouts; + pageType: EmbedPageType | null; +}) => { + // We keep width 100% for mobile because we want it to take the entire screen width, which it would take because skeletonContainer is taking entire screen width + const mobileStyleForSkeleton = "width:100%;"; + // We don't do width:100% here because we want skeleton to be centered horizontally and not take the entire screen width in desktop + const desktopStyleForSkeleton = "left:50%; transform:translate(-50%,0%)"; + + const styleForSkeleton = layout === "mobile" ? mobileStyleForSkeleton : desktopStyleForSkeleton; + + // height is set via JS to be same as skeleton inside it + // Width 100% so that the entire width is available just like for iframe + const styleForSkeletonContainer = "width:100%;"; + + return { + skeletonContent: generateSkeleton({ layout, pageType }), + skeletonContainerStyle: styleForSkeletonContainer, + skeletonStyle: styleForSkeleton, + }; +}; export default html; diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts index 606237f694..feb91c3817 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBox.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBox.ts @@ -1,25 +1,17 @@ +import { EmbedElement } from "../EmbedElement"; import loaderCss from "../loader.css"; import { getErrorString } from "../utils"; -import modalBoxHtml from "./ModalBoxHtml"; +import modalBoxHtml, { getSkeletonData } from "./ModalBoxHtml"; -type ShadowRootWithStyle = ShadowRoot & { - host: HTMLElement & { style: CSSStyleDeclaration }; -}; - -export class ModalBox extends HTMLElement { +export class ModalBox extends EmbedElement { static htmlOverflow: string; + // eslint-disable-next-line @typescript-eslint/ban-ts-comment //@ts-ignore static get observedAttributes() { return ["state"]; } - assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRootWithStyle } { - if (!this.shadowRoot) { - throw new Error("No shadow root"); - } - } - show(show: boolean) { this.assertHasShadowRoot(); // We can't make it display none as that takes iframe width and height calculations to 0 @@ -68,18 +60,7 @@ export class ModalBox extends HTMLElement { } } - getLoaderElement() { - this.assertHasShadowRoot(); - const loaderEl = this.shadowRoot.querySelector(".loader"); - - if (!loaderEl) { - throw new Error("No loader element"); - } - - return loaderEl; - } - - getErrorElement() { + getErrorElement(): HTMLElement { this.assertHasShadowRoot(); const element = this.shadowRoot.querySelector("#error"); @@ -98,15 +79,16 @@ export class ModalBox extends HTMLElement { if (newValue === "loading") { this.open(); this.hideIframe(); - this.getLoaderElement().style.display = "block"; + this.toggleLoader(true); } else if (newValue == "loaded" || newValue === "reopening") { this.open(); this.showIframe(); - this.getLoaderElement().style.display = "none"; + this.toggleLoader(false); } else if (newValue == "closed") { this.explicitClose(); } else if (newValue === "failed") { this.getLoaderElement().style.display = "none"; + this.getSkeletonElement().style.display = "none"; this.getErrorElement().style.display = "inline-block"; const errorString = getErrorString(this.dataset.errorCode); this.getErrorElement().innerText = errorString; @@ -116,6 +98,7 @@ export class ModalBox extends HTMLElement { } connectedCallback() { + super.connectedCallback(); this.assertHasShadowRoot(); const closeEl = this.shadowRoot.querySelector(".close"); document.addEventListener( @@ -144,8 +127,11 @@ export class ModalBox extends HTMLElement { } constructor() { - super(); - const modalHtml = `${modalBoxHtml}`; + super({ isModal: true, getSkeletonData }); + const modalHtml = `${modalBoxHtml({ + layout: this.getLayout(), + pageType: this.getPageType() ?? null, + })}`; this.attachShadow({ mode: "open" }); ModalBox.htmlOverflow = document.body.style.overflow; document.body.style.overflow = "hidden"; diff --git a/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts b/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts index 5617d2c9d1..9c1e0ce82e 100644 --- a/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts +++ b/packages/embeds/embed-core/src/ModalBox/ModalBoxHtml.ts @@ -1,59 +1,103 @@ -const html = ` + `; } -.modal-box { - margin:0 auto; - margin-top:20px; - margin-bottom:20px; - position:absolute; - width:100%; - top:50%; - left:50%; - transform: translateY(-50%) translateX(-50%); - overflow: auto; -} - -.header { - position: relative; - float:right; - top: 10px; -} -.close { - font-size: 30px; - left: -20px; - position: relative; - color:white; - cursor: pointer; -} -/*Modal background is black only, so hardcode white */ -.loader { - --cal-brand-color:white; -} - +const html = ({ + layout = "month_view", + pageType, +}: { + layout?: AllPossibleLayouts; + pageType: EmbedPageType | null; +}) => { + const { skeletonContent, skeletonContainerStyle, skeletonStyle } = getSkeletonData({ + layout, + pageType, + }); + return ` +${getStyle()}
-
- -
-