feat: Embed - Show skeleton loader when pageType is provided (#20646)

* 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

* Remove PR_TODO

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
This commit is contained in:
Hariom Balhara
2025-04-16 13:40:51 +00:00
committed by GitHub
co-authored by Benny Joo amrit
parent 15aaf7f51b
commit 9bc703a437
23 changed files with 1648 additions and 271 deletions
+22
View File
@@ -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
+167 -139
View File
@@ -4,8 +4,13 @@
<!-- <link rel="prerender" href="http://localhost:3000/free"> -->
<!-- <script src="./src/embed.ts" type="module"></script> -->
<script>
const url = new URL(document.URL);
window.only = url.searchParams.get("only")
window.calOrigin = url.searchParams.get("calOrigin") || "http://localhost:3000";
let popupRescheduleUid = null;
let calLink = null;
const initialNamespace = (url.searchParams.get("only") || "").split("ns:")[1] || "";
(function defineVariables() {
const searchParams = new URL(document.URL).searchParams;
popupRescheduleUid = searchParams.get("popupRescheduleUid") || "qm3kwt3aTnVD7vmP9tiT2f";
@@ -24,7 +29,7 @@
if (!location.search.includes("nonResponsive")) {
document.write('<meta name="viewport" content="width=device-width"/>');
}
(function scrollToIframeInPlayground() {
function scrollToIframeInPlayground() {
const url = new URL(document.URL);
// Only run the example specified by only=, avoids distraction and faster to test.
const only = (window.only = url.searchParams.get("only") || '');
@@ -32,12 +37,12 @@
if (elementIdentifier) {
location.hash = "#cal-booking-place-" + elementIdentifier + "-iframe";
}
})();
}
function addInlineEmbedInNewNamespaceWithoutReload(selector) {
Cal("init", "withoutReloadNamespace", {
debug: true,
calOrigin: "http://localhost:3000",
calOrigin: window.calOrigin,
});
Cal.ns.withoutReloadNamespace("inline", {
@@ -85,7 +90,7 @@
}
p(cal, ar);
};
})(window, "//localhost:3000/embed/embed.js", "init");
})(window, window.calOrigin + "/embed/embed.js", "init");
}
embedSnippet();
</script>
@@ -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 @@
});
}
</script>
<style>
body {
background: linear-gradient(
@@ -193,137 +198,138 @@
</style>
</head>
<body>
<div>
<button id="add-inline-embed-in-a-new-namespace-without-reload-button" onclick="addInlineEmbedInNewNamespaceWithoutReload('#add-inline-embed-in-a-new-namespace-without-reload-button-content')">Add Inline Embed in a new namespace without reload</button>
<div id="add-inline-embed-in-a-new-namespace-without-reload-button-content"></div>
</div>
<div>
<button id="double-install-snippet-with-inline-embed-default-namespace-button" onclick="doubleInstallSnippetWithInlineEmbed('#double-install-snippet-with-inline-embed-default-namespace-content')">Double Install Embed Snippet with inline embed - Default Namespace</button>
<div id="double-install-snippet-with-inline-embed-default-namespace-content"></div>
</div>
<div>
<button id="double-install-snippet-with-inline-embed-non-default-namespace-button" onclick="doubleInstallSnippetWithInlineEmbedWithNonDefaultNamespace('#double-install-snippet-with-inline-embed-non-default-namespace-content')">Double Install Embed Snippet with inline embed - Non Default Namespace</button>
<div id="double-install-snippet-with-inline-embed-non-default-namespace-content"></div>
</div>
<div>
<button id="two-different-namespace-with-different-init-config" onclick="addTwoInlineEmbedWithTwoDifferentNamespacesWithDifferentInitConfig()">Two different namespace with two different config</button>
<div id="two-different-namespace-with-different-init-config-area">
<div id="two-different-namespace-with-different-init-config-content-1"></div>
<div id="two-different-namespace-with-different-init-config-content-2"></div>
<div id="misc-embeds">
<div>
<button id="add-inline-embed-in-a-new-namespace-without-reload-button" onclick="addInlineEmbedInNewNamespaceWithoutReload('#add-inline-embed-in-a-new-namespace-without-reload-button-content')">Add Inline Embed in a new namespace without reload</button>
<div id="add-inline-embed-in-a-new-namespace-without-reload-button-content"></div>
</div>
</div>
<span style="display: block"><a href="?color-scheme=dark">With Dark Color Scheme for the Page</a></span>
<span style="display: block"><a href="?nonResponsive">Non responsive version of this page here</a></span>
<span style="display: block">
<a href="?name=john&email=email&guests=abc&guests=def&notes=NOTES&location=%7B%22optionValue%22%3A%22%2B919999999999%22%2C%22value%22%3A%22phone%22%7D&only=ns%3ApageParamsForwarding">
Page Params Forwarding(Prefills various fields by auto-forwarding query params)
</a>
</span>
<span style="display: block"
><a href="?only=prerender-test">Go to Prerender test page only</a><small></small
<div>
<button id="double-install-snippet-with-inline-embed-default-namespace-button" onclick="doubleInstallSnippetWithInlineEmbed('#double-install-snippet-with-inline-embed-default-namespace-content')">Double Install Embed Snippet with inline embed - Default Namespace</button>
<div id="double-install-snippet-with-inline-embed-default-namespace-content"></div>
</div>
<div>
<button id="double-install-snippet-with-inline-embed-non-default-namespace-button" onclick="doubleInstallSnippetWithInlineEmbedWithNonDefaultNamespace('#double-install-snippet-with-inline-embed-non-default-namespace-content')">Double Install Embed Snippet with inline embed - Non Default Namespace</button>
<div id="double-install-snippet-with-inline-embed-non-default-namespace-content"></div>
</div>
<div>
<button id="two-different-namespace-with-different-init-config" onclick="addTwoInlineEmbedWithTwoDifferentNamespacesWithDifferentInitConfig()">Two different namespace with two different config</button>
<div id="two-different-namespace-with-different-init-config-area">
<div id="two-different-namespace-with-different-init-config-content-1"></div>
<div id="two-different-namespace-with-different-init-config-content-2"></div>
</div>
</div>
<span style="display: block"><a href="?color-scheme=dark">With Dark Color Scheme for the Page</a></span>
<span style="display: block"><a href="?nonResponsive">Non responsive version of this page here</a></span>
<span style="display: block">
<a href="?name=john&email=email&guests=abc&guests=def&notes=NOTES&location=%7B%22optionValue%22%3A%22%2B919999999999%22%2C%22value%22%3A%22phone%22%7D&only=ns%3ApageParamsForwarding">
Page Params Forwarding(Prefills various fields by auto-forwarding query params)
</a>
</span>
<span style="display: block"
><a href="?only=prerender-test">Go to Prerender test page only</a><small></small
></span>
<span style="display: block"
><a href="?only=preload-test">Go to Preload test page only</a><small></small
></span>
<span style="display: block"
><a href="?only=preload-test">Go to Preload test page only</a><small></small
></span>
<button onclick="document.documentElement.style.colorScheme='dark'">Toggle Dark Scheme(For Embedding Page)</button>
<button onclick="document.documentElement.style.colorScheme='light'">Toggle Light Scheme(For Embedding Page)</button>
<div>
<script>
if (only === "all" || only === "prerender-test") {
document.write(`
<button data-cal-namespace="e2ePrerenderLightTheme" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled", "flag.coep": "true"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
<i
>Corresponding Cal Link is being prerendered. Assuming that it would take you some time to click this
as you are reading this text, it would open up super fast[If you are running a production build on
local]. Try switching to slow 3G or create a custom Network configuration which is impossibly
slow. This should be used if you know beforehand which type of embed is going to be opened.</i
>`);
}
if (only === "all" || only === "preload-test") {
document.write(`
<button data-cal-namespace="preloadTest" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled", "flag.coep": "true"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
<i
>Corresponding Cal Link is being preloaded. That means that all the resources would be preloaded. This could be useful in preloading possible resources if you don't know before hand which type of embed you want to show</i
>`);
}
</script>
</div>
<span style="display: block"
><a href="?only=all">Render All embeds together</a><small> - It would be slow to load</small></span
>
<div>
<a href="?only=ns:floatingButton">Floating Popup</a>
<button onclick="document.documentElement.style.colorScheme='dark'">Toggle Dark Scheme(For Embedding Page)</button>
<button onclick="document.documentElement.style.colorScheme='light'">Toggle Light Scheme(For Embedding Page)</button>
<div>
<script>
if (only === "all" || only === "prerender-test") {
document.write(`
<button data-cal-namespace="e2ePrerenderLightTheme" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled", "flag.coep": "true"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
<i
>Corresponding Cal Link is being prerendered. Assuming that it would take you some time to click this
as you are reading this text, it would open up super fast[If you are running a production build on
local]. Try switching to slow 3G or create a custom Network configuration which is impossibly
slow. This should be used if you know beforehand which type of embed is going to be opened.</i
>`);
}
if (only === "all" || only === "preload-test") {
document.write(`
<button data-cal-namespace="preloadTest" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled", "flag.coep": "true"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
<i
>Corresponding Cal Link is being preloaded. That means that all the resources would be preloaded. This could be useful in preloading possible resources if you don't know before hand which type of embed you want to show</i
>`);
}
</script>
</div>
<span style="display: block"
><a href="?only=all">Render All embeds together</a><small> - It would be slow to load</small></span
>
<div>
<a href="?only=ns:floatingButton">Floating Popup</a>
<h2>Popup Examples - Theme Tests</h2>
<button data-cal-namespace="e2ePopupLightTheme" data-cal-link="free" data-cal-config='{"theme":"light", "flag.coep": "true"}'>Book an event with Free[Light Theme]</button>
<button data-cal-namespace="popupAutoTheme" data-cal-link="free" data-cal-config='{"flag.coep": "true"}'>
Book with Free User[Auto Theme]
</button>
<button data-cal-namespace="popupDarkTheme" data-cal-config='{"theme":"dark", "flag.coep": "true"}' data-cal-link="free">
Book with Free User[Dark Theme]
</button>
<button
data-cal-namespace="popupTeamLinkLightTheme"
data-cal-config='{"theme":"light", "flag.coep": "true"}'
data-cal-link="team/seeded-team/collective-seeded-team-event">
Book with Test Team[Light Theme]
</button>
<button
data-cal-namespace="popupTeamLinkDarkTheme"
data-cal-config='{"theme":"dark", "flag.coep": "true"}'
data-cal-link="team/seeded-team/collective-seeded-team-event">
Book with Test Team[Dark Theme]
</button>
<button data-cal-namespace="popupTeamLinksList" data-cal-link="team/seeded-team/" data-cal-config='{"flag.coep": "true"}'>
See Team Links [Auto Theme]
</button>
<button data-cal-namespace="popupPaidEvent" data-cal-link="pro/paid" data-cal-config='{"flag.coep": "true"}'>
Book Paid Event [Auto Theme]
</button>
<h2>Popup Examples - Reschedule Tests</h2>
<script>
document.write(
`<button data-cal-namespace="popupRescheduleWithReschedulePath" data-cal-config='{"flag.coep":"true"}' data-cal-link="reschedule/${popupRescheduleUid}">Reschedule Event(with /reschedule) - Needs popupRescheduleUid param</button>`
);
</script>
<script>
document.write(
`<button data-cal-namespace="popupRescheduleWithRescheduleUidParam" data-cal-config='{"flag.coep":"true", "rescheduleUid": "${popupRescheduleUid}"}' data-cal-link="${calLink}">Reschedule Event(with rescheduleUid param) - Needs popupRescheduleUid and calLink param</button>`
);
</script>
<h2>Popup Examples - Different Configurations</h2>
<button data-cal-namespace="popupHideEventTypeDetails" data-cal-link="free/30min" data-cal-config='{"flag.coep": "true"}'>
Book Free Event [Auto Theme][uiConfig.hideEventTypeDetails=true]
</button>
<button data-cal-namespace="popupPaidEvent" data-cal-config='{"layout":"week_view", "flag.coep": "true"}' data-cal-link="pro/paid">
Book Paid Event - weekly view
</button>
<button data-cal-namespace="popupPaidEvent" data-cal-config='{"layout":"column_view", "flag.coep": "true"}' data-cal-link="pro/paid">
Book Paid Event - column view
</button>
<h2>Popup Examples - Routing Form Tests</h2>
<button data-cal-namespace="routingFormAuto" data-cal-link="forms/948ae412-d995-4865-875a-48302588de03" data-cal-config='{"flag.coep": "true"}'>
Book through Routing Form [Auto Theme] - Test for 'routed' event
</button>
<button
data-cal-namespace="routingFormDark"
data-cal-config='{"theme":"dark", "flag.coep": "true"}'
data-cal-link="forms/948ae412-d995-4865-875a-48302588de03">
Book through Routing Form [Dark Theme]
</button>
<button data-cal-namespace="routingFormHeadlessRouter" data-cal-link="router?form=948ae412-d995-4865-875a-48302588de03&Test field=event-routing" data-cal-config='{"flag.coep": "true"}'>
Book through Headless Router
</button>
<h2>Popup Examples - Child Element Target Tests</h2>
<a style="display: block;" data-cal-namespace="childElementTarget" href="javascript:void(0)" data-cal-link="free/30min" data-cal-config='{"flag.coep": "true"}'>
I am Anchor
<b>[I am Bold inside anchor]</b>
<span>
I am span inside anchor
</span>
</a>
</div>
<h2>Popup Examples - Theme Tests</h2>
<button data-cal-namespace="e2ePopupLightTheme" data-cal-link="free" data-cal-config='{"theme":"light", "flag.coep": "true"}'>Book an event with Free[Light Theme]</button>
<button data-cal-namespace="popupAutoTheme" data-cal-link="free" data-cal-config='{"flag.coep": "true"}'>
Book with Free User[Auto Theme]
</button>
<button data-cal-namespace="popupDarkTheme" data-cal-config='{"theme":"dark", "flag.coep": "true"}' data-cal-link="free">
Book with Free User[Dark Theme]
</button>
<button
data-cal-namespace="popupTeamLinkLightTheme"
data-cal-config='{"theme":"light", "flag.coep": "true"}'
data-cal-link="team/seeded-team/collective-seeded-team-event">
Book with Test Team[Light Theme]
</button>
<button
data-cal-namespace="popupTeamLinkDarkTheme"
data-cal-config='{"theme":"dark", "flag.coep": "true"}'
data-cal-link="team/seeded-team/collective-seeded-team-event">
Book with Test Team[Dark Theme]
</button>
<button data-cal-namespace="popupTeamLinksList" data-cal-link="team/seeded-team/" data-cal-config='{"flag.coep": "true"}'>
See Team Links [Auto Theme]
</button>
<button data-cal-namespace="popupPaidEvent" data-cal-link="pro/paid" data-cal-config='{"flag.coep": "true"}'>
Book Paid Event [Auto Theme]
</button>
<h2>Popup Examples - Reschedule Tests</h2>
<script>
document.write(
`<button data-cal-namespace="popupRescheduleWithReschedulePath" data-cal-config='{"flag.coep":"true"}' data-cal-link="reschedule/${popupRescheduleUid}">Reschedule Event(with /reschedule) - Needs popupRescheduleUid param</button>`
);
</script>
<script>
document.write(
`<button data-cal-namespace="popupRescheduleWithRescheduleUidParam" data-cal-config='{"flag.coep":"true", "rescheduleUid": "${popupRescheduleUid}"}' data-cal-link="${calLink}">Reschedule Event(with rescheduleUid param) - Needs popupRescheduleUid and calLink param</button>`
);
</script>
<h2>Popup Examples - Different Configurations</h2>
<button data-cal-namespace="popupHideEventTypeDetails" data-cal-link="free/30min" data-cal-config='{"flag.coep": "true"}'>
Book Free Event [Auto Theme][uiConfig.hideEventTypeDetails=true]
</button>
<button data-cal-namespace="popupPaidEvent" data-cal-config='{"layout":"week_view", "flag.coep": "true"}' data-cal-link="pro/paid">
Book Paid Event - weekly view
</button>
<button data-cal-namespace="popupPaidEvent" data-cal-config='{"layout":"column_view", "flag.coep": "true"}' data-cal-link="pro/paid">
Book Paid Event - column view
</button>
<h2>Popup Examples - Routing Form Tests</h2>
<button data-cal-namespace="routingFormAuto" data-cal-link="forms/948ae412-d995-4865-875a-48302588de03" data-cal-config='{"flag.coep": "true"}'>
Book through Routing Form [Auto Theme] - Test for 'routed' event
</button>
<button
data-cal-namespace="routingFormDark"
data-cal-config='{"theme":"dark", "flag.coep": "true"}'
data-cal-link="forms/948ae412-d995-4865-875a-48302588de03">
Book through Routing Form [Dark Theme]
</button>
<button data-cal-namespace="routingFormHeadlessRouter" data-cal-link="router?form=948ae412-d995-4865-875a-48302588de03&Test field=event-routing" data-cal-config='{"flag.coep": "true"}'>
Book through Headless Router
</button>
<h2>Popup Examples - Child Element Target Tests</h2>
<a style="display: block;" data-cal-namespace="childElementTarget" href="javascript:void(0)" data-cal-link="free/30min" data-cal-config='{"flag.coep": "true"}'>
I am Anchor
<b>[I am Bold inside anchor]</b>
<span>
I am span inside anchor
</span>
</a>
</div>
<h2>Inline Embed Examples</h2>
<div id="namespaces-test">
@@ -425,7 +431,6 @@
<div class="dark"></div>
<i>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</i>
</div>
</div>
<div class="inline-embed-container" id="cal-booking-place-monthView">
<h3><a href="?only=ns:monthView">Test Month View</a></h3>
<div class="place" style="width: 100%"></div>
@@ -451,6 +456,29 @@
<h3><a href="?only=ns:autoScrollTest">Test Auto Scroll</a></h3>
<div class="place" style="width: 100%"></div>
</div>
</div>
<div id="cal-booking-place-skeletonDemo">
<h3><a href="?only=ns:skeletonDemo">Skeleton Loader INLINE Demo</a></h3>
<div class="inline-embed-container">
<div id="cal-booking-place-skeletonDemo">
<div class="place"></div>
</div>
</div>
</div>
<div id="cal-booking-place-skeletonDemoElementClick">
<a href="?only=ns:skeletonDemoElementClick">Skeleton Loader MODAL Demo</a>
<h3><button data-cal-namespace="skeletonDemoElementClick" data-cal-link="team/seeded-team/collective-seeded-team-event" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots"}'>Skeleton Loader MODAL Demo</button></h3>
<div class="inline-embed-container">
<div id="cal-booking-place-skeletonDemoElementClick">
<div class="place"></div>
</div>
</div>
</div>
<script type="module" src="./playground.ts"></script>
<script>
if (initialNamespace.startsWith("skeletonDemo")) {
document.getElementById("misc-embeds").style.display = "none";
}
</script>
</body>
</html>
+2
View File
@@ -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",
+77 -1
View File
@@ -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);
})();
@@ -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<string, string>;
}) {
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: `<div id="skeleton-content" class="${layout}-layout ${pageType}-pageType">Skeleton Content</div>`,
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);
});
});
});
});
@@ -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<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 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);
}
}
+12 -14
View File
@@ -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<HTMLElement>(".loader");
const errorEl = this.shadowRoot.querySelector<HTMLElement>("#error");
const slotEl = this.shadowRoot.querySelector<HTMLElement>("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 = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${inlineHtml}`;
this.shadowRoot.innerHTML = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${inlineHtml({
layout: this.layout,
pageType: this.getPageType() ?? null,
})}`;
}
}
@@ -1,10 +1,58 @@
const html = `<div id="wrapper" style="top:50%; left:50%;transform:translate(-50%,-50%)" class="absolute z-highest">
<div class="loader border-brand-default dark:border-darkmodebrand">
<span class="loader-inner bg-brand dark:bg-darkmodebrand"></span>
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 `
<div id="skeleton-container" style="${skeletonContainerStyle}">
<div id="skeleton" style="${skeletonStyle}" class="absolute z-highest">
${skeletonContent}
</div>
<div id="wrapper" style="top:50%; left:50%; transform:translate(-50%,-50%)" class="absolute z-highest">
<div class="loader border-brand-default dark:border-darkmodebrand">
<span class="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
<div id="error" style="transform:translate(-50%,-50%)" class="hidden">
Something went wrong.
</div>
</div>
</div>
<div id="error" style="transform:translate(-50%,-50%)" class="hidden">
Something went wrong.
</div>
</div>
<slot></slot>`;
<slot></slot>
`;
};
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;
@@ -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<HTMLElement>(".loader");
if (!loaderEl) {
throw new Error("No loader element");
}
return loaderEl;
}
getErrorElement() {
getErrorElement(): HTMLElement {
this.assertHasShadowRoot();
const element = this.shadowRoot.querySelector<HTMLElement>("#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<HTMLElement>(".close");
document.addEventListener(
@@ -144,8 +127,11 @@ export class ModalBox extends HTMLElement {
}
constructor() {
super();
const modalHtml = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${modalBoxHtml}`;
super({ isModal: true, getSkeletonData });
const modalHtml = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${modalBoxHtml({
layout: this.getLayout(),
pageType: this.getPageType() ?? null,
})}`;
this.attachShadow({ mode: "open" });
ModalBox.htmlOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
@@ -1,59 +1,103 @@
const html = `<style>
.my-backdrop {
position:fixed;
width:100%;
height:100%;
top:0;
left:0;
z-index:999999999999;
display:block;
background-color:rgb(5,5,5, 0.8)
import type { AllPossibleLayouts, EmbedPageType } from "../types";
import { generateSkeleton } from "../ui/skeleton";
function getStyle() {
return `
<style>
.my-backdrop {
position:fixed;
width:100%;
height:100%;
top:0;
left:0;
z-index:999999999999;
display:block;
background-color:rgb(5,5,5, 0.8)
}
.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;
}
</style>
`;
}
.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;
}
</style>
const html = ({
layout = "month_view",
pageType,
}: {
layout?: AllPossibleLayouts;
pageType: EmbedPageType | null;
}) => {
const { skeletonContent, skeletonContainerStyle, skeletonStyle } = getSkeletonData({
layout,
pageType,
});
return `
${getStyle()}
<div class="my-backdrop">
<div class="header">
<button type="button" class="close" aria-label="Close">&times;</button>
</div>
<div class="modal-box">
<div class="body">
<div id="wrapper" class="z-[999999999999] absolute flex w-full items-center">
<div class="loader modal-loader border-brand-default dark:border-darkmodebrand">
<span class="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
</div>
<div id="error" class="hidden left-1/2 -translate-x-1/2 relative text-inverted"></div>
<slot></slot>
<div class="header">
<button type="button" class="close" aria-label="Close">&times;</button>
</div>
<div class="modal-box">
<div class="body" id="skeleton-container" style="${skeletonContainerStyle}">
<div id="wrapper" class="z-[999999999999] absolute flex w-full items-center">
<div class="loader modal-loader border-brand-default dark:border-darkmodebrand">
<span class="loader-inner bg-brand dark:bg-darkmodebrand"></span>
</div>
</div>
<div id="skeleton" style="${skeletonStyle}" class="absolute z-highest">
${skeletonContent}
</div>
<div id="error" class="hidden left-1/2 -translate-x-1/2 relative text-inverted"></div>
<slot></slot>
</div>
</div>
</div>
</div>`;
};
export const getSkeletonData = ({
layout,
pageType,
}: {
layout: AllPossibleLayouts;
pageType: EmbedPageType | null;
}) => {
const mobileStyleForSkeleton = "width:100%;";
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;
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { generateDataAttributes } from "../utils";
describe("generateDataAttributes", () => {
it("should handle PascalCase property names correctly", () => {
const props = {
TestKey: "testValue",
AnotherKey: "anotherValue",
};
expect(generateDataAttributes(props)).toBe('data-test-key="testValue" data-another-key="anotherValue"');
});
it("should handle camelCase property names correctly", () => {
const props = {
camelCaseKey: "value",
superLongCamelCaseKey: "test",
};
expect(generateDataAttributes(props)).toBe(
'data-camel-case-key="value" data-super-long-camel-case-key="test"'
);
});
it("should filter out null and undefined values", () => {
const props = {
validKey: "value",
nullKey: null,
undefinedKey: undefined,
};
expect(generateDataAttributes(props)).toBe('data-valid-key="value"');
});
it("should return empty string for empty object", () => {
const props = {};
expect(generateDataAttributes(props)).toBe("");
});
it("should handle object with all null/undefined values", () => {
const props = {
key1: null,
key2: undefined,
};
expect(generateDataAttributes(props)).toBe("");
});
it("should escape special characters in attribute values", () => {
const props = {
testKey: "<script>alert('XSS')</script>",
testKey2: '<script>alert("XSS")</script>',
};
expect(generateDataAttributes(props)).toBe(
'data-test-key="&lt;script&gt;alert(&#39;XSS&#39;)&lt;/script&gt;" data-test-key2="&lt;script&gt;alert(&quot;XSS&quot;)&lt;/script&gt;"'
);
});
});
+103 -2
View File
@@ -1,6 +1,8 @@
export const addAppCssVars = () => {
const calElements = ["cal-floating-button", "cal-modal-box", "cal-inline"];
try {
const cssVarsStyle = `cal-floating-button, cal-modal-box, cal-inline {
const cssVarsStyle = `
${calElements.join(",")} {
/* Spacing Scale */
--cal-spacing-0: 0px;
--cal-spacing-px: 1px;
@@ -144,7 +146,106 @@ export const addAppCssVars = () => {
--cal-brand: hsla(221, 39%, 11%, 1); /* Keeping existing brand color */
--cal-brand-emphasis: hsla(0, 0%, 6%, 1); /* Keeping existing brand emphasis */
--cal-brand-text: hsla(0, 0%, 100%, 1); /* white */
}`;
}
${calElements.map((element) => `${element}.dark`).join(",")} {
/* Background Standard */
--cal-bg-emphasis: hsla(0, 0%, 25%, 1); /* stone-700 */
--cal-bg: hsla(0, 0%, 6%, 1); /* stone-950 */
--cal-bg-subtle: hsla(0, 0%, 15%, 1); /* stone-800 */
--cal-bg-muted: hsla(0, 0%, 9%, 1); /* stone-900 */
--cal-bg-inverted: hsla(0, 0%, 98%, 1); /* stone-50 */
/* Background Primary */
--cal-bg-primary: hsla(0, 0%, 96%, 1); /* stone-100 */
--cal-bg-primary-emphasis: hsla(0, 0%, 64%, 1); /* stone-800 */
--cal-bg-primary-muted: hsla(0, 0%, 15%, 1); /* stone-400 */
/* Background Brand */
--cal-bg-brand: hsla(0, 0%, 98%, 1); /* gray-50 */
--cal-bg-brand-emphasis: hsla(0, 0%, 96%, 1); /* stone-100 */
--cal-bg-brand-muted: hsla(0, 0%, 96%, 1); /* stone-100 */
/* Background Semantic */
--cal-bg-semantic-info-subtle: hsla(236, 80%, 8%, 1); /* blue-800 */
--cal-bg-semantic-info-emphasis: hsla(229, 90%, 74%, 1); /* blue-500 */
--cal-bg-semantic-success-subtle: hsla(148, 88%, 16%, 1); /* green-800 */
--cal-bg-semantic-success-emphasis: hsla(158, 74%, 38%, 1); /* green-500 */
--cal-bg-semantic-attention-subtle: hsla(21, 86%, 8%, 1); /* orange-800 */
--cal-bg-semantic-attention-emphasis: hsla(27, 96%, 61%, 1); /* orange-500 */
--cal-bg-semantic-error-subtle: hsla(0, 70%, 8%, 1); /* red-800 */
--cal-bg-semantic-error-emphasis: hsla(0, 91%, 71%, 1); /* red-500 */
/* Background Visualization */
--cal-bg-visualization-1-subtle: hsla(336, 74%, 35%, 1); /* pink-200 */
--cal-bg-visualization-1-emphasis: hsla(330, 81%, 60%, 1); /* pink-500 */
--cal-bg-visualization-2-subtle: hsla(252, 83%, 23%, 1); /* purple-200 */
--cal-bg-visualization-2-emphasis: hsla(256, 85%, 57%, 1); /* purple-500 */
--cal-bg-visualization-3-subtle: hsla(236, 74%, 35%, 1); /* blue-200 */
--cal-bg-visualization-3-emphasis: hsla(235, 100%, 63%, 1); /* blue-500 */
--cal-bg-visualization-4-subtle: hsla(150, 84%, 22%, 1); /* green-200 */
--cal-bg-visualization-4-emphasis: hsla(158, 74%, 38%, 1); /* green-500 */
--cal-bg-visualization-5-subtle: hsla(28, 73%, 26%, 1); /* yellow-200 */
--cal-bg-visualization-5-emphasis: hsla(45, 93%, 47%, 1); /* yellow-500 */
--cal-bg-visualization-6-subtle: hsla(15, 75%, 23%, 1); /* orange-200 */
--cal-bg-visualization-6-emphasis: hsla(25, 95%, 53%, 1); /* orange-500 */
--cal-bg-visualization-7-subtle: hsla(0, 70%, 35%, 1); /* red-200 */
--cal-bg-visualization-7-emphasis: hsla(0, 84%, 60%, 1); /* red-500 */
/* Legacy Background Components - Consider deprecating */
--cal-bg-info: hsla(228, 56%, 33%, 1); /* Keeping existing info background */
--cal-bg-success: hsla(133, 34%, 24%, 1); /* Keeping existing success background */
--cal-bg-attention: hsla(16, 62%, 28%, 1); /* Keeping existing attention background */
--cal-bg-error: hsla(2, 55%, 30%, 1); /* Keeping existing error background */
--cal-bg-dark-error: hsla(2, 55%, 30%, 1); /* Keeping existing dark error */
/* Border Standard */
--cal-border: hsla(0, 0%, 30%, 1); /* stone-600 */
--cal-border-muted: hsla(0, 0%, 9%, 1); /* stone-500 */
--cal-border-subtle: hsla(0, 0%, 15%, 1); /* stone-700 */
--cal-border-emphasis: hsla(0, 0%, 45%, 1); /* stone-800 */
--cal-border-booker: var(--cal-border-subtle);
/* Border Semantic */
--cal-border-semantic-error: hsla(0, 63%, 24%, 1); /* red-800 */
/* Border/Semantic Subtle */
--cal-border-semantic-attention-subtle: hsla(15, 75%, 23%, 1);
--cal-border-semantic-error-subtle: hsla(0, 63%, 24%, 1);
/* Legacy Border - Consider deprecating */
--cal-border-error: hsla(0, 63%, 24%, 1); /* Keeping existing error border */
/* Content/Text Standard */
--cal-text-emphasis: hsla(0, 0%, 98%, 1); /* stone-50 */
--cal-text: hsla(0, 0%, 83%, 1); /* stone-300 */
--cal-text-subtle: hsla(0, 0%, 64%, 1); /* stone-400 */
--cal-text-muted: hsla(0, 0%, 64%, 1); /* stone-400 */
--cal-text-inverted: hsla(0, 0%, 0%, 1); /* black */
/* Content/Text Semantic */
--cal-text-semantic-info: hsla(229, 90%, 74%, 1); /* blue-100 */
--cal-text-semantic-success: hsla(161, 49%, 54%, 1); /* green-100 */
--cal-text-semantic-attention: hsla(27, 96%, 61%, 1); /* orange-100 */
--cal-text-semantic-error: hsla(0, 91%, 71%, 1); /* red-100 */
/* Content/Text Semantic Emphasis */
--cal-text-semantic-info-emphasis: hsla(217, 91%, 25%, 1); /* blue-800 */
--cal-text-semantic-success-emphasis: hsla(142, 71%, 25%, 1); /* green-800 */
--cal-text-semantic-attention-emphasis: hsla(24, 95%, 25%, 1); /* orange-800 */
--cal-text-semantic-error-emphasis: hsla(0, 84%, 25%, 1); /* red-800 */
/* Legacy Content/Text Components - Consider deprecating */
--cal-text-info: hsla(218, 83%, 93%, 1); /* Keeping existing info text */
--cal-text-success: hsla(134, 76%, 94%, 1); /* Keeping existing success text */
--cal-text-attention: hsla(37, 86%, 92%, 1); /* Keeping existing attention text */
--cal-text-error: hsla(3, 66%, 93%, 1); /* Keeping existing error text */
/* Brand */
--cal-brand: hsla(0, 0%, 100%, 1); /* white */
--cal-brand-emphasis: hsla(218, 11%, 65%, 1); /* Keeping existing brand emphasis */
--cal-brand-text: hsla(0, 0%, 0%, 1); /* black */
}
`;
// Create a style rule for above css
const style = document.createElement("style");
+37 -16
View File
@@ -5,7 +5,14 @@ import { useEffect, useRef, useState, useCallback } from "react";
import type { Message } from "./embed";
import { sdkActionManager } from "./sdk-event";
import type { EmbedThemeConfig, UiConfig, EmbedNonStylesConfig, BookerLayouts, EmbedStyles } from "./types";
import type {
EmbedThemeConfig,
UiConfig,
EmbedNonStylesConfig,
BookerLayouts,
EmbedStyles,
KnownConfig,
} from "./types";
import { useCompatSearchParams } from "./useCompatSearchParams";
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
@@ -23,18 +30,7 @@ export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Rec
iframeAttrs?: Record<string, string> & {
id?: string;
};
// TODO: It should have a dedicated prefill prop
// prefill: {},
"flag.coep"?: "true" | "false";
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
// ui: {layout; theme}
layout?: BookerLayouts;
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
"ui.color-scheme"?: string;
theme?: EmbedThemeConfig;
};
} & KnownConfig;
declare global {
interface Window {
@@ -43,6 +39,8 @@ declare global {
embedStore: typeof embedStore;
applyCssVars: (cssVarsPerTheme: UiConfig["cssVarsPerTheme"]) => void;
};
// Marks that Booker has moved to some non-"loading" state
_embedBookerState?: "initializing" | "done";
}
}
@@ -81,6 +79,7 @@ const embedStore = {
// Store all React State setters here.
reactStylesStateSetters: {} as Record<keyof EmbedStyles, SetStyles>,
reactNonStylesStateSetters: {} as Record<keyof EmbedNonStylesConfig, setNonStylesConfig>,
// Embed can show itself only after this is set to true
parentInformedAboutContentHeight: false,
windowLoadEventFired: false,
setTheme: undefined as ((arg0: EmbedThemeConfig) => void) | undefined,
@@ -310,6 +309,14 @@ function getEmbedType() {
}
}
function isBookerReady() {
return window._embedBookerState === "done";
}
function isBookerPage() {
return !!window._embedBookerState;
}
export const useIsEmbed = (embedSsr?: boolean) => {
const [isEmbed, setIsEmbed] = useState(embedSsr);
useEffect(() => {
@@ -334,11 +341,11 @@ export const useEmbedType = () => {
};
function unhideBody() {
if (document.body.style.visibility !== "visible") {
document.body.style.visibility = "visible";
}
// Ensure that it stays visible and not reverted by React
runAsap(() => {
if (document.body.style.visibility !== "visible") {
document.body.style.visibility = "visible";
}
unhideBody();
});
}
@@ -392,12 +399,22 @@ const methods = {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
parentKnowsIframeReady: (_unused: unknown) => {
log("Method: `parentKnowsIframeReady` called");
runAsap(function tryInformingLinkReady() {
// TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight
if (!embedStore.parentInformedAboutContentHeight) {
runAsap(tryInformingLinkReady);
return;
}
// Let's wait for Booker to be ready before showing the embed
// It means that booker has loaded all its data and is ready to show
// TODO: We could try to mark the embed as ready earlier in this case not relying on document.readyState
if (isBookerPage() && !isBookerReady()) {
runAsap(tryInformingLinkReady);
return;
}
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
unhideBody();
if (!isPrerendering()) {
@@ -460,10 +477,12 @@ function keepParentInformedAboutDimensionChanges() {
}, 100);
return;
}
if (!embedStore.windowLoadEventFired) {
sdkActionManager?.fire("__windowLoadComplete", {});
}
embedStore.windowLoadEventFired = true;
// Use the dimensions of main element as in most places there is max-width restriction on it and we just want to show the main content.
// It avoids the unwanted padding outside main tag.
const mainElement =
@@ -495,7 +514,9 @@ function keepParentInformedAboutDimensionChanges() {
// On subsequent renders, consider html height as the height of the iframe. If we don't do this, then if iframe gets bigger in height, it would never shrink
const iframeHeight = isFirstTime ? documentScrollHeight : contentHeight;
const iframeWidth = isFirstTime ? documentScrollWidth : contentWidth;
embedStore.parentInformedAboutContentHeight = true;
if (!iframeHeight || !iframeWidth) {
runAsap(informAboutScroll);
return;
+31 -9
View File
@@ -9,7 +9,8 @@ import { SdkActionManager } from "./sdk-action-manager";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import tailwindCss from "./tailwindCss";
import type { UiConfig } from "./types";
import { fromEntriesWithDuplicateKeys } from "./utils";
import { getMaxHeightForModal } from "./ui-utils";
import { fromEntriesWithDuplicateKeys, getConfigProp, generateDataAttributes } from "./utils";
export type { PrefillAndIframeAttrsConfig } from "./embed-iframe";
@@ -26,6 +27,8 @@ export type Message = {
// HACK: Redefine and don't import WEBAPP_URL as it causes import statement to be present in built file.
// This is happening because we are not able to generate an App and a lib using single Vite Config.
const WEBAPP_URL = process.env.EMBED_PUBLIC_WEBAPP_URL || `https://${process.env.EMBED_PUBLIC_VERCEL_URL}`;
// Add App CSS Vars as soon as possible so that tailwind classes can work instantly.
addAppCssVars();
customElements.define("cal-modal-box", ModalBox);
customElements.define("cal-floating-button", FloatingButton);
@@ -373,10 +376,7 @@ export class Cal {
}
if (this.modalBox) {
// It ensures that if the iframe is so tall that it can't fit in the parent window without scroll. Then force the scroll by restricting the max-height to innerHeight
// This case is reproducible when viewing in ModalBox on Mobile.
const spacingTopPlusBottom = 2 * 50; // 50 is the padding we want to keep to show close button comfortably. Make it same as top for bottom.
iframe.style.maxHeight = `${window.innerHeight - spacingTopPlusBottom}px`;
iframe.style.maxHeight = `${getMaxHeightForModal()}px`;
}
});
@@ -572,8 +572,21 @@ class CalApi {
iframe.style.width = "100%";
containerEl.classList.add("cal-inline-container");
const template = document.createElement("template");
template.innerHTML = `<cal-inline style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%"></cal-inline><style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
const layout = getConfigProp(config, "layout");
const pageType = getConfigProp(config, "cal.embed.pageType");
const theme = getConfigProp(config, "theme");
template.innerHTML = `<cal-inline
${generateDataAttributes({
pageType,
theme,
layout,
})}
style="max-height:inherit;height:inherit;min-height:inherit;display:flex;position:relative;flex-wrap:wrap;width:100%">
</cal-inline>
<style>.cal-inline-container::-webkit-scrollbar{display:none}.cal-inline-container{scrollbar-width:none}</style>`;
this.cal.inlineEl = template.content.children[0];
this.cal.inlineEl.appendChild(iframe);
containerEl.appendChild(template.content);
@@ -715,7 +728,18 @@ class CalApi {
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
const pageType = getConfigProp(configWithGuestKeyAndColorScheme, "cal.embed.pageType");
const theme = getConfigProp(configWithGuestKeyAndColorScheme, "theme");
const layout = getConfigProp(configWithGuestKeyAndColorScheme, "layout");
template.innerHTML = `<cal-modal-box
${generateDataAttributes({
pageType,
theme,
layout,
})}
uid="${uid}">
</cal-modal-box>`;
this.cal.modalBox = template.content.children[0];
this.cal.modalBox.appendChild(iframe);
if (__prerender) {
@@ -1056,8 +1080,6 @@ function initializeGlobalCalProps() {
globalCal.config.forwardQueryParams = globalCal.config.forwardQueryParams ?? false;
}
addAppCssVars();
function log(...args: unknown[]) {
console.log(...args);
}
+27
View File
@@ -4,6 +4,7 @@ type Theme = "dark" | "light";
export type EmbedThemeConfig = Theme | "auto";
export type BookerLayouts = "month_view" | "week_view" | "column_view";
export type AllPossibleLayouts = BookerLayouts | "mobile";
// Only allow certain styles to be modified so that when we make any changes to HTML, we know what all embed styles might be impacted.
// Keep this list to minimum, only adding those styles which are really needed.
export interface EmbedStyles {
@@ -42,4 +43,30 @@ declare global {
}
}
export type EmbedPageType =
// First page of booker for team event
| "team.event.booking.slots"
// First page of booker for user event
| "user.event.booking.slots"
// Booking Form(Second page of booker) for team event
| "team.event.booking.form"
// Booking Form(Second page of booker) for user event
| "user.event.booking.form";
export type KnownConfig = {
// TODO: It should have a dedicated prefill prop
// prefill: {},
"flag.coep"?: "true" | "false";
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
// ui: {layout; theme}
layout?: BookerLayouts;
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
"ui.color-scheme"?: string;
theme?: EmbedThemeConfig;
// Prefixing with cal.embed because there could be more query params that aren't required by embed and are used for things like prefilling booking form, configuring dry run, and some other params simply to be forwarded to the booking success redirect URL.
// There are some cal. prefixed query params as well, not meant for embed specifically, but in general for cal.com
"cal.embed.pageType"?: EmbedPageType;
};
export {};
@@ -0,0 +1,66 @@
import type { AllPossibleLayouts, EmbedThemeConfig } from "./types";
export function getMaxHeightForModal() {
const spacingTopPlusBottom = 2 * 50; // 50 is the padding we want to keep to show close button comfortably. Make it same as top for bottom.
// It ensures that if the iframe is so tall that it can't fit in the parent window without scroll. Then force the scroll by restricting the max-height to innerHeight
// This case is reproducible when viewing in ModalBox on Mobile.
return window.innerHeight - spacingTopPlusBottom;
}
function matchesMediaQuery(query: string) {
return window.matchMedia(query).matches;
}
export function getTrueLayout({ layout }: { layout: AllPossibleLayouts | null }) {
const isMobile = matchesMediaQuery("(max-width: 768px)");
if (isMobile) {
return "mobile";
}
const defaultLayout = "month_view";
// If layout is mobile and isMobile false, then we need to reset to defaultLayout
if (layout === "mobile") {
return defaultLayout;
}
return layout ?? defaultLayout;
}
function detectColorScheme() {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
return "dark";
}
return "light";
}
function getClassBasedOnTheme(theme: EmbedThemeConfig | undefined | null) {
return theme ?? "light";
}
export function isThemePreferenceProvided(theme: EmbedThemeConfig | undefined | null) {
return theme === "dark" || theme === "light";
}
export function getThemeClassForEmbed({ theme }: { theme: EmbedThemeConfig | undefined | null }) {
const systemTheme = detectColorScheme();
if (isThemePreferenceProvided(theme)) {
return getClassBasedOnTheme(theme);
}
// NOTE: We don't support App configured theme as we can't fetch those details here. User has to explicitly set the theme in the embed snippet or use system theme
return getClassBasedOnTheme(systemTheme);
}
let colorSchemeDarkQuery: MediaQueryList | null = null;
export function getColorSchemeDarkQuery() {
if (!colorSchemeDarkQuery) {
colorSchemeDarkQuery = window.matchMedia("(prefers-color-scheme: dark)");
}
return colorSchemeDarkQuery;
}
export function addDarkColorSchemeChangeListener(listener: (e: MediaQueryListEvent) => void) {
getColorSchemeDarkQuery().addEventListener("change", listener);
}
export function removeDarkColorSchemeChangeListener(listener: (e: MediaQueryListEvent) => void) {
getColorSchemeDarkQuery()?.removeEventListener("change", listener);
}
@@ -0,0 +1,158 @@
import type { AllPossibleLayouts, EmbedPageType } from "../types";
const generateEventMetaSkeleton = () => {
const eventMetaOptions = Array.from({ length: 4 })
.map(
() => `
<div class="flex items-start justify-start text-sm text-text">
<div class="animate-pulse bg-emphasis h-4 w-4 mr-2 rounded-sm"></div>
<div class="animate-pulse bg-emphasis h-4 w-40 rounded-sm"></div>
</div>
`
)
.join("");
return `
<div class="relative z-10 p-6" data-testid="event-meta">
<div style="opacity: 1; transform: none;">
<ul class="flex items-center border-muted">
<li class="-mr-1 inline-block">
<span class="bg-emphasis relative inline-flex aspect-square items-center justify-center border align-top w-6 h-6 min-w-6 min-h-6 rounded-full overflow-hidden border-subtle">
<div class="animate-pulse bg-emphasis w-full h-full"></div>
</span>
</li>
</ul>
<p class="text-subtle mt-2 text-sm font-semibold">
<div class="animate-pulse bg-emphasis h-4 w-24 rounded-sm"></div>
</p>
<h1 data-testid="event-title" class="text-text text-xl font-semibold my-2 mb-4">
<div class="animate-pulse bg-emphasis h-6 w-48 rounded-sm"></div>
</h1>
<div class="space-y-2 font-medium rtl:-mr-2">
${eventMetaOptions}
</div>
</div>
</div>
`;
};
const generateDatePickerSkeleton = () => {
const dayLabels = Array(7)
.fill(0)
.map(
() => `
<div class="text-emphasis my-4 text-xs font-medium uppercase tracking-widest">
<div class="animate-pulse bg-emphasis h-4 w-8 rounded-sm"></div>
</div>
`
)
.join("");
const datesToRender = 7 * 5; // columns * rows in a month view
const dates = Array.from({ length: datesToRender })
.map(
() => `
<div class="relative w-full pt-[100%]">
<button class="bg-muted text-muted absolute bottom-0 left-0 right-0 top-0 mx-auto flex w-full items-center justify-center rounded-sm border-transparent text-center font-medium opacity-90 transition" disabled="">
<span class="font-size-0 bg-emphasis inline-block animate-pulse rounded-md empty:before:inline-block empty:before:content-[''] h-9 w-9"></span>
</button>
</div>`
)
.join("");
return `
<div>
<div class="mb-1 flex items-center justify-between text-xl">
<span class="text-default w-1/2 text-base">
<time datetime="2025-05" data-testid="selected-month-label">
<span class="text-emphasis font-semibold">
<div class="animate-pulse bg-emphasis h-4 w-20 rounded-sm"></div>
</span>
</time>
</span>
<div class="text-emphasis">
<div class="flex">
<button class="group whitespace-nowrap items-center font-medium relative rounded-[10px] disabled:cursor-not-allowed gap-1 flex justify-center text-subtle border border-transparent enabled:hover:bg-subtle enabled:hover:text-emphasis enabled:hover:border-subtle hover:border disabled:opacity-30 focus-visible:bg-subtle focus-visible:outline-none focus-visible:ring-0 focus-visible:border-subtle focus-visible:shadow-button-outline-gray-focused enabled:active:shadow-outline-gray-active duration-200 text-sm leading-none min-h-[36px] min-w-[36px] !p-2 hover:border-default group p-1 opacity-70 transition hover:opacity-100 rtl:rotate-180" data-testid="decrementMonth" aria-label="View previous month" type="button">
<svg height="16" width="16" class="fill-transparent visible button-icon group-active:translate-y-[0.5px] h-4 w-4" data-name="start-icon" aria-hidden="true">
<use href="#chevron-left"></use>
</svg>
<div class="contents visible group-active:translate-y-[0.5px]"></div>
</button>
<button class="group whitespace-nowrap items-center font-medium relative rounded-[10px] disabled:cursor-not-allowed gap-1 flex justify-center text-subtle border border-transparent enabled:hover:bg-subtle enabled:hover:text-emphasis enabled:hover:border-subtle hover:border disabled:opacity-30 focus-visible:bg-subtle focus-visible:outline-none focus-visible:ring-0 focus-visible:border-subtle focus-visible:shadow-button-outline-gray-focused enabled:active:shadow-outline-gray-active duration-200 text-sm leading-none min-h-[36px] min-w-[36px] !p-2 hover:border-default group p-1 opacity-70 transition hover:opacity-100 rtl:rotate-180 undefined" data-testid="incrementMonth" aria-label="View next month" type="button">
<svg height="16" width="16" class="fill-transparent visible button-icon group-active:translate-y-[0.5px] h-4 w-4" data-name="start-icon" aria-hidden="true">
<use href="#chevron-right"></use>
</svg>
<div class="contents visible group-active:translate-y-[0.5px]"></div>
</button>
</div>
</div>
</div>
<div class="border-subtle mb-2 grid grid-cols-7 gap-4 border-b border-t text-center md:mb-0 md:border-0">
${dayLabels}
</div>
<div class="relative grid grid-cols-7 grid-rows-6 gap-1 text-center">
${dates}
</div>
</div>`;
};
const generateBookingSlotsPageSkeleton = ({ layout }: { layout: AllPossibleLayouts | null }) => {
if (layout === "mobile") {
return `
<div
data-testid="booker-container"
class="[--booker-timeslots-width:240px] lg:[--booker-timeslots-width:280px] [--booker-main-width:480px] [--booker-meta-width:340px] lg:[--booker-meta-width:424px] bg-default dark:bg-muted grid max-w-full items-start dark:[color-scheme:dark] sm:transition-[width] sm:duration-300 sm:motion-reduce:transition-none md:flex-row border-subtle rounded-md"
style="grid-template-areas: &quot;meta&quot; &quot;header&quot; &quot;main&quot; &quot;timeslots&quot;; width: 100%; grid-template-columns: 100%; grid-template-rows: minmax(min-content, max-content) 1fr; min-height: 0px; height: auto;">
<div
class="relative z-10 flex [grid-area:meta]">
<div class="[grid-area:meta] max-w-screen flex w-full flex-col md:w-[var(--booker-meta-width)]">
${generateEventMetaSkeleton()}
<div class="mt-auto px-5 py-3">
${generateDatePickerSkeleton()}
</div>
</div>
</div>
<div class="[grid-area:main] md:border-subtle ml-[-1px] h-full flex-shrink px-5 py-3 md:border-l lg:w-[var(--booker-main-width)]" style="opacity: 1; transform: none;">
</div>
</div>
`;
}
// TODO: Support different layouts. Right now we show month view skeleton for all layouts
return `
<div
data-testid="booker-container"
class="[--booker-timeslots-width:240px] lg:[--booker-timeslots-width:280px] [--booker-meta-width:240px] [--booker-main-width:480px] lg:[--booker-meta-width:280px] bg-default dark:bg-muted grid max-w-full items-start dark:[color-scheme:dark] sm:motion-reduce:transition-none md:flex-row rounded-md sm:transition-[width] sm:duration-300 border-subtle border undefined"
style="grid-template-areas: &quot;meta main main&quot; &quot;meta main main&quot;; width: calc(var(--booker-meta-width) + var(--booker-main-width)); grid-template-columns: var(--booker-meta-width) var(--booker-main-width); grid-template-rows: 1fr 0fr; min-height: 450px; height: auto;">
<div
class="relative z-10 flex [grid-area:meta]"
style="position: sticky; top: 0px;">
${generateEventMetaSkeleton()}
</div>
<div class="[grid-area:main] md:border-subtle ml-[-1px] h-full flex-shrink px-5 py-3 md:border-l lg:w-[var(--booker-main-width)]" style="opacity: 1; transform: none;">
${generateDatePickerSkeleton()}
</div>
</div>
`;
};
const generateSkeleton = ({
layout = "month_view",
pageType,
}: {
layout: AllPossibleLayouts | null;
pageType: EmbedPageType | null;
}) => {
const defaultSkeleton = generateBookingSlotsPageSkeleton({ layout });
if (!pageType) {
// Ideally we shouldn't be here if pageType is nullish, but just in case
return defaultSkeleton;
}
if (pageType === "user.event.booking.slots" || pageType === "team.event.booking.slots") {
return generateBookingSlotsPageSkeleton({ layout });
}
// TODO: Support skeletons for other pages like user.event.booking.form, team.event.booking.form and profile pages
return defaultSkeleton;
};
export { generateSkeleton };
+63
View File
@@ -1,3 +1,5 @@
import type { KnownConfig } from "./types";
export const getErrorString = (errorCode: string | undefined) => {
if (errorCode === "404") {
return `Error Code: 404. Cal Link seems to be wrong.`;
@@ -35,3 +37,64 @@ export function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string,
}
return result;
}
function listKnownConfigProps() {
const knownConfigProps: (keyof KnownConfig)[] = [
"flag.coep",
"layout",
"ui.color-scheme",
"theme",
"cal.embed.pageType",
];
return knownConfigProps;
}
export function getConfigProp<TProp extends keyof KnownConfig>(config: KnownConfig, prop: TProp) {
const knownConfigProps = listKnownConfigProps();
if (!knownConfigProps.includes(prop)) {
console.warn(`Not reading unknown config prop: ${prop}`);
return null;
}
return config[prop] ?? null;
}
export function getQueryParamProvidedByConfig<TProp extends keyof KnownConfig>(
queryParam: TProp
): NonNullable<KnownConfig[TProp]> | null {
const knownConfigProps = listKnownConfigProps();
if (!knownConfigProps.includes(queryParam)) {
console.warn(`Not reading unknown config prop from query: ${queryParam}`);
return null;
}
const url = new URL(document.URL);
const value = url.searchParams.get(queryParam);
if (typeof value === "string") {
return value as NonNullable<KnownConfig[TProp]>;
}
return value;
}
function hyphenate(str: string): string {
return str.charAt(0).toLowerCase() + str.slice(1).replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
}
/**
* Escapes special characters in HTML attribute values to prevent XSS
* @param value The string to escape
* @returns Escaped string safe for use in HTML attributes
*/
function escapeHtmlAttribute(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
}
export function generateDataAttributes(props: Record<string, string | null | undefined>): string {
return Object.entries(props)
.filter((pair): pair is [string, string] => !!pair[1])
.map(([key, value]) => `data-${hyphenate(key)}="${escapeHtmlAttribute(value)}"`)
.join(" ");
}
@@ -2,7 +2,7 @@ const base = require("@calcom/config/tailwind-preset");
module.exports = {
...base,
content: ["**/*Html.ts"],
content: ["**/*Html.ts", "**/ui/**/*.ts*"],
theme: {
...base.theme,
extend: {
@@ -0,0 +1,63 @@
import { beforeEach, afterEach, vi } from "vitest";
let __matchMediaFakeQuery: string[] = [];
// Mock matchMedia - Not Provided by JSDOM
function mockWindowMatchMedia() {
window.matchMedia = vi.fn().mockImplementation((query: string) => {
const eventListeners: Record<string, ((event: MediaQueryListEvent) => void)[]> = {
change: [],
};
const matches = __matchMediaFakeQuery.includes(query);
return {
matches,
media: query,
onchange: null,
addEventListener: (event: string, listener: (event: MediaQueryListEvent) => void) => {
if (event === "change") {
console.log("addEventListener called", event, listener);
eventListeners.change.push(listener);
}
},
removeEventListener: (event: string, listener: (event: MediaQueryListEvent) => void) => {
if (event === "change") {
eventListeners.change = eventListeners.change.filter((l) => l !== listener);
console.log("removeEventListener called, Remaining listeners", eventListeners.change.length);
}
},
dispatchEvent: (event: MediaQueryListEvent) => {
if (event.type === "change") {
eventListeners.change.forEach((listener) => {
console.log("listener called", listener);
listener(event as MediaQueryListEvent);
});
}
},
};
});
}
// Call immediately so that imports accessing window.matchMedia on import can access the mock
mockWindowMatchMedia();
beforeEach(() => {
// Do on beforeEach because vi.fn() gets reset after each test
mockWindowMatchMedia();
});
afterEach(() => {
__matchMediaFakeQuery = [];
});
export function fakeDeviceMatchesMediaQuery(query: string) {
// Extract the query type (e.g., max-width or prefers-color-scheme)
const queryType = query.match(/(max-width|prefers-color-scheme)/)?.[0];
if (queryType) {
// Remove any existing queries of the same type
__matchMediaFakeQuery = __matchMediaFakeQuery.filter(
(existingQuery) => !existingQuery.includes(queryType)
);
}
// Add the new query
__matchMediaFakeQuery.push(query);
console.log("__mock__:fakeDeviceMatchesMediaQuery updated", { query, __matchMediaFakeQuery });
}
+1
View File
@@ -1,5 +1,6 @@
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<script src="https://unpkg.com/react@17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js" crossorigin></script>
<script type="module" src="./inline.tsx"></script>
+1
View File
@@ -90,6 +90,7 @@ function App() {
notes: "Test Meeting",
guests: ["janedoe@gmail.com"],
theme: "dark",
"cal.embed.pageType": "user.event.booking.slots",
}}
/>
</>
+11 -1
View File
@@ -38,10 +38,18 @@ import { useIsQuickAvailabilityCheckFeatureEnabled } from "./components/hooks/us
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
import framerFeatures from "./framer-features";
import { useBookerStore } from "./store";
import type { BookerProps, WrappedBookerProps } from "./types";
import type { BookerProps, WrappedBookerProps, BookerState } from "./types";
import { isBookingDryRun } from "./utils/isBookingDryRun";
import { isTimeSlotAvailable } from "./utils/isTimeslotAvailable";
function updateEmbedBookerState({ bookerState }: { bookerState: BookerState }) {
// Ensure that only after the bookerState is reflected, we update the embedIsBookerReady
if (typeof window !== "undefined") {
(window as Window & { _embedBookerState?: "initializing" | "done" })._embedBookerState =
bookerState && bookerState !== "loading" ? "done" : "initializing";
}
}
const BookerComponent = ({
username,
eventSlug,
@@ -178,6 +186,8 @@ const BookerComponent = ({
(bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep))
);
updateEmbedBookerState({ bookerState });
useEffect(() => {
if (event.isPending) return setBookerState("loading");
if (!selectedDate) return setBookerState("selecting_date");