Files
calendar/packages/embeds/embed-core/src/EmbedElement.ts
T
d6546c3107 feat: upgrade tailwind v4 (#24598)
* chore: refactor config files to prevent migration tool errors

* refactor: upgrade with the tailwind migration tool

* chore: restore pre-commit command + mc

* refactor(wip): update dependencies and migrate to Tailwind CSS v4 (mainly web)

* chore: resolve Tailwind v4 migration conflicts from merging main

* chore: remove unused Tailwind packages from config and update dependencies for v4 migration

* chore: uncomment Tailwind CSS utility classes in globals.css

* fix: resolve token conflicts between calcom and coss ui

* fix: textarea scrollbar

* Fix CUI-16

* fix: added @tailwindcss/forms plugin and cleaned up CSS classes in various components to remove unnecessary dark mode styles

* fix: selects and inputs of different sizes

* fix: remove unnecessary leading-20 class from modal titles in various components

* fix: update Checkbox component styles to remove unnecessary border on checked state

* fix: clean up styles in RequiresConfirmationController, Checkbox, and Radio components to enhance consistency

* fix: update button and filter component styles to remove unnecessary rounded classes for consistency

* fix: calendar

* fix: update KBarSearch

* fix: refine styles in Empty and Checkbox components for improved consistency

* Fix focus state email input

* fix: update button hover and active states to use 'not-disabled' instead of 'enabled'

* fix: line-height issues

* fix: update class name for muted background in BookingListItem component

* fix: sidebar spacing

* chore: update class names to use new Tailwind CSS color utilities

* fix embed

* chore: upgrade Tailwind CSS to version 4.1.16 and update related dependencies

* Map css variables and add a playground test for heavy css customization

* suggestion for coss-ui

* refactor: update CSS variable usage and clean up styles

- Replace instances of `--cal-brand-color` with `--cal-brand` in embed-related HTML files.
- Remove the now-unnecessary `addAppCssVars` function from the embed core.
- Import theme tokens in the embed core styles for better consistency.
- Clean up whitespace and formatting in CSS files for improved readability.
- Add a comment in `tokens.css` regarding its usage in both embed and webapp contexts.

* Handle within tokens.css instead of fixing coss-ui

* Remove initial, not needed. Also, remove tailwind.config.js as tailwidn scans the html automaically

* fix: examples app breaking

* fix: modal not resizing correctly

* feat: upgrade atoms to tailwind v4

* fix: atoms build breaking

* fix: atoms build breaking

* chore: upgrate examples/base to tailwind 4

* chore: update globals.css

* fix: add missing scheduler css variables

* fix: PlatformAdditionalCalendarSelector

* chore: update global styles

* chore: update tailwindcss and postcss dependencies to stable versions

* chore: remove unneeded class

* fix: dialog and toast animation

* fix: replace flex-shrink-0 with shrink-0 for consistent styling in various components

* fix: dialog modal for Apple connect

* add margin in SaveFilterSegmentButton

* Fix radix button nested states

* add cursor pointer to buttons but keep dsabled state

* Fix commandK selectors and adds cursor pointer

* Fix teams filter

* fix - round checkboxes

* fix filter checkbox

* fix select indicator's margin

* command group font size

* style: fix badge and tooltip radius

* chore: remove unneeded files

* Delete PR_REVIEW_MANAGED_EVENT_REASSIGNMENT.md

* remove ui-playground leftover

* fix: add missing react phone input styles in atoms

* Delete managed-event-reassignment-flow-and-architecture.mermaid

* fix: inter font not loading

* Add theme to skeleton container so that it can support dark mode

* fix: create custom stack-y-* utilities post tw4 upgrade

* fix: typo

* fix: atoms stack class + remove unused css file

* fix default radius valiue

* fix space-y in embed

* fix skeleton background

* Hardcode radius values to match production

* fix border in embed

* add missing externalThemeClass

* feat: create a custom stack-y-* utility

* fix: add stack utility to atom global css

* fix: Skeleton loader class modalbox

* Add stack-y utility in embed

* fix: add missing stack utilities in atoms globals.css

* update yarn.lock

* add popover portla

* update

---------

Co-authored-by: Sean Brydon <sean@cal.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Ryukemeister <sahalrajiv6900@gmail.com>
Co-authored-by: cal.com <morgan@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Anik Dhabal Babu <adhabal2002@gmail.com>
2025-11-25 17:32:28 -03:00

232 lines
8.7 KiB
TypeScript

import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
import type { EmbedThemeConfig, AllPossibleLayouts, BookerLayouts, EmbedPageType } from "./types";
import {
getThemeClassForEmbed,
getTrueLayout,
isThemePreferenceProvided,
removeDarkColorSchemeChangeListener,
addDarkColorSchemeChangeListener,
getMaxHeightForModal,
} from "./ui-utils";
import type { ExternalThemeClass } from "./ui/themeClass";
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!: ExternalThemeClass;
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();
// Any pageType being set is considered as skeleton supported. There is always a fallback skeleton loader if no direct match for a skeleton loader is found based on pageType
return !!pageType;
}
public assertHasShadowRoot(): asserts this is HTMLElement & { shadowRoot: ShadowRootWithStyle } {
if (!this.shadowRoot) {
throw new Error("No shadow root");
}
}
public getPageType(): EmbedPageType | undefined {
return this.dataset.pageType as EmbedPageType | undefined;
}
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.setTheme(this.theme);
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 setTheme(theme: EmbedThemeConfig | null) {
const allPossibleThemeClasses = [EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS];
const newThemeClass = getThemeClassForEmbed({ theme });
if (newThemeClass === this.themeClass) {
return;
}
this.themeClass = newThemeClass;
this.classList.remove(...allPossibleThemeClasses);
this.classList.add(this.themeClass);
}
public prefersDarkThemeChangedHandler(e: MediaQueryListEvent) {
const isDarkPreferred = e.matches;
if (isThemePreferenceProvided(this.theme)) {
// User has provided a theme preference, so we stick to that and don't react to system theme change
return;
}
this.setTheme(isDarkPreferred ? "dark" : "light");
}
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);
}
}