* mvp done * wip * fix ts errors and other code improvements * fix ts errors * ensure mobile layout support * Make skeleton responsive on screen resize * refactor * Add test for EmbedElement * make skeleton closer to pixel perfect * Address PR feedback * Router-preloading ## What does this PR do? <!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. --> - Fixes #XXXX (GitHub issue number) - Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description) ## Visual Demo (For contributors especially) A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**. #### Video Demo (if applicable): - Show screen recordings of the issue or feature. - Demonstrate how to reproduce the issue, the behavior before and after the change. #### Image Demo (if applicable): - Add side-by-side screenshots of the original and updated change. - Highlight any significant change(s). ## Mandatory Tasks (DO NOT REMOVE) - [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected). - [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox. - [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works. ## How should this be tested? <!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests --> - Are there environment variables that should be set? - What are the minimal test data to have? - What is expected (happy path) to have (input and output)? - Any other important info that could help to test that PR ## Checklist <!-- Remove bullet points below that don't apply to you --> - I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md) - My code doesn't follow the style guidelines of this project - I haven't commented my code, particularly in hard-to-understand areas - I haven't checked if my changes generate no new warnings * wip\ * wip * fix mrge.io feedback * wip * Add README and lifecycle * Add README and lifecycle * Update routing form-seed and some other fixes * remove linkFailed fix from the branch * self-review * self-review-2 * self-review-3 * Handle soft connect\ * Update README and fix a bug with query parmas * Add one more case in routing-html playground --------- Co-authored-by: Benny Joo <sldisek783@gmail.com> Co-authored-by: amrit <iamamrit27@gmail.com>
231 lines
8.5 KiB
TypeScript
231 lines
8.5 KiB
TypeScript
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<HTMLElement>("#skeleton-container");
|
|
if (!element) {
|
|
throw new Error("No skeleton container element");
|
|
}
|
|
return element;
|
|
}
|
|
|
|
getSkeletonElement(): HTMLElement {
|
|
this.assertHasShadowRoot();
|
|
const element = this.shadowRoot.querySelector<HTMLElement>("#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<HTMLElement>(".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 isSkeletonLoaderVisible() {
|
|
const skeletonEl = this.getSkeletonElement();
|
|
// Comparing with "none" which is set by toggleLoader when skeleton is hidden
|
|
return skeletonEl.style.display !== "none";
|
|
}
|
|
|
|
public resizeHandler() {
|
|
const newLayout = getTrueLayout({ layout: this.layout ?? null });
|
|
if (newLayout === this.layout) {
|
|
return;
|
|
}
|
|
this.layout = newLayout;
|
|
|
|
// We can't accidentaly show skeleton if it isn't showing
|
|
if (!this.isSkeletonLoaderVisible()) {
|
|
return;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|