fix: Embed - Non transparent background when color-scheme of embedding document and cal.com iframe don't match (#10033)
## What does this PR do? Fixes #10032 Before: https://www.loom.com/share/1890e4eebf41426aaf66d8b4909d0765 After: https://www.loom.com/share/8b00c41587f2467ab05ef48e4ed523ef ## Type of change <!-- Please delete bullets that are not relevant. --> - Bug fix (non-breaking change which fixes an issue) ## How should this be tested? - See loom ## Mandatory Tasks - [x] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected. ## Checklist - I haven't added tests that prove my fix is effective or that my feature works
This commit is contained in:
1 parent
34d65f67a9
commit
11081cb5d8
5 files changed
+132
-18
No files matched your search
@@ -28,17 +28,18 @@ class MyDocument extends Document<Props> {
|
||||
// Use a dummy URL as default so that URL parsing works for relative URLs as well. We care about searchParams and pathname only
|
||||
const parsedUrl = new URL(asPath, "https://dummyurl");
|
||||
const isEmbed = parsedUrl.pathname.endsWith("/embed") || parsedUrl.searchParams.get("embedType") !== null;
|
||||
const embedColorScheme = parsedUrl.searchParams.get("ui.color-scheme");
|
||||
const initialProps = await Document.getInitialProps(ctx);
|
||||
return { isEmbed, nonce, ...initialProps };
|
||||
return { isEmbed, embedColorScheme, nonce, ...initialProps };
|
||||
}
|
||||
|
||||
render() {
|
||||
const { locale } = this.props.__NEXT_DATA__;
|
||||
const { isEmbed } = this.props;
|
||||
const { isEmbed, embedColorScheme } = this.props;
|
||||
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
||||
return (
|
||||
<Html lang={locale}>
|
||||
<Html lang={locale} style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||
<Head nonce={nonce}>
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/api/logo?type=apple-touch-icon" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/api/logo?type=favicon-32" />
|
||||
|
||||
@@ -82,10 +82,13 @@
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<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="?only=prerender-test">Go to Pre-render test page only</a><small></small
|
||||
></span>
|
||||
<button onclick="document.documentElement.style.colorScheme='dark'">Toggle Dark Scheme</button>
|
||||
<button onclick="document.documentElement.style.colorScheme='light'">Toggle Light Scheme</button>
|
||||
<div>
|
||||
<script>
|
||||
if (only === "all" || only === "prerender-test") {
|
||||
|
||||
@@ -6,8 +6,28 @@ const callback = function (e) {
|
||||
console.log("Event: ", e.type, detail);
|
||||
};
|
||||
|
||||
document.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if ("href" in target && typeof target.href === "string") {
|
||||
const toUrl = new URL(target.href);
|
||||
const pageUrl = new URL(document.URL);
|
||||
for (const [name, value] of pageUrl.searchParams.entries()) {
|
||||
if (toUrl.searchParams.get(name) === null) {
|
||||
toUrl.searchParams.append(decodeURIComponent(name), value);
|
||||
}
|
||||
}
|
||||
location.href = `?${toUrl.searchParams.toString()}#${toUrl.hash}`;
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
const searchParams = new URL(document.URL).searchParams;
|
||||
const only = searchParams.get("only");
|
||||
const colorScheme = searchParams.get("color-scheme");
|
||||
|
||||
if (colorScheme) {
|
||||
document.documentElement.style.colorScheme = colorScheme;
|
||||
}
|
||||
const themeInParam = searchParams.get("theme");
|
||||
const validThemes = ["light", "dark", "auto"] as const;
|
||||
const theme = validThemes.includes((themeInParam as (typeof validThemes)[number]) || "")
|
||||
|
||||
@@ -18,6 +18,7 @@ export type UiConfig = {
|
||||
//TODO: Extract from tailwind the list of all custom variables and support them in auto-completion as well as runtime validation. Followup with listing all variables in Embed Snippet Generator UI.
|
||||
cssVarsPerTheme?: Record<Theme, Record<string, string>>;
|
||||
layout?: BookerLayouts;
|
||||
colorScheme?: string | null;
|
||||
};
|
||||
|
||||
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
|
||||
@@ -336,6 +337,10 @@ const methods = {
|
||||
window.CalEmbed.applyCssVars(uiConfig.cssVarsPerTheme);
|
||||
}
|
||||
|
||||
if (uiConfig.colorScheme) {
|
||||
actOnColorScheme(uiConfig.colorScheme);
|
||||
}
|
||||
|
||||
if (embedStore.setUiConfig) {
|
||||
runAllUiSetters(uiConfig);
|
||||
}
|
||||
@@ -461,10 +466,12 @@ if (isBrowser) {
|
||||
|
||||
embedStore.uiConfig = {
|
||||
// TODO: Add theme as well here
|
||||
// theme:
|
||||
colorScheme: url.searchParams.get("ui.color-scheme"),
|
||||
layout: url.searchParams.get("layout") as BookerLayouts,
|
||||
};
|
||||
|
||||
actOnColorScheme(embedStore.uiConfig.colorScheme);
|
||||
|
||||
if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) {
|
||||
log("Initializing embed-iframe");
|
||||
// HACK
|
||||
@@ -523,3 +530,10 @@ function runAllUiSetters(uiConfig: UiConfig) {
|
||||
embedStore.uiConfig = uiConfig;
|
||||
embedStore.setUiConfig.forEach((setUiConfig) => setUiConfig(uiConfig));
|
||||
}
|
||||
|
||||
function actOnColorScheme(colorScheme: string | null | undefined) {
|
||||
if (!colorScheme) {
|
||||
return;
|
||||
}
|
||||
document.documentElement.style.colorScheme = colorScheme;
|
||||
}
|
||||
@@ -116,6 +116,31 @@ function validate(data: Record<string, unknown>, schema: ValidationSchema) {
|
||||
}
|
||||
}
|
||||
|
||||
function getColorScheme(el: Element) {
|
||||
const pageColorScheme = getComputedStyle(el).colorScheme;
|
||||
if (pageColorScheme === "dark" || pageColorScheme === "light") {
|
||||
return pageColorScheme;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function withColorScheme(
|
||||
queryObject: PrefillAndIframeAttrsConfig & { guest?: string | string[] },
|
||||
containerEl: Element
|
||||
) {
|
||||
// If color-scheme not explicitly configured, keep it same as the webpage that has the iframe
|
||||
// This is done to avoid having an opaque background of iframe that arises when they aren't same. We really need to have a transparent background to make embed part of the page
|
||||
// https://fvsch.com/transparent-iframes#:~:text=the%20resolution%20was%3A-,If%20the%20color%20scheme%20of%20an%20iframe%20differs%20from%20embedding%20document%2C%20iframe%20gets%20an%20opaque%20canvas%20background%20appropriate%20to%20its%20color%20scheme.,-So%20the%20dark
|
||||
if (!queryObject["ui.color-scheme"]) {
|
||||
const colorScheme = getColorScheme(containerEl);
|
||||
// Only handle two color-schemes for now. We don't want to have unintented affect by always explicitly adding color-scheme
|
||||
if (colorScheme) {
|
||||
queryObject["ui.color-scheme"] = colorScheme;
|
||||
}
|
||||
}
|
||||
return queryObject;
|
||||
}
|
||||
|
||||
type SingleInstructionMap = {
|
||||
// TODO: This makes api("on", {}) loose it's generic type. Find a way to fix it.
|
||||
// e.g. api("on", { action: "__dimensionChanged", callback: (e) => { /* `e.detail.data` has all possible values for all events/actions */} });
|
||||
@@ -139,8 +164,8 @@ export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Rec
|
||||
// TODO: It should have a dedicated prefill prop
|
||||
// prefill: {},
|
||||
|
||||
// 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}
|
||||
// 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;
|
||||
layout?: `${BookerLayouts}`;
|
||||
theme?: EmbedThemeConfig;
|
||||
};
|
||||
@@ -369,7 +394,7 @@ export class Cal {
|
||||
|
||||
class CalApi {
|
||||
cal: Cal;
|
||||
|
||||
static initializedNamespaces = [] as string[];
|
||||
constructor(cal: Cal) {
|
||||
this.cal = cal;
|
||||
}
|
||||
@@ -379,6 +404,8 @@ class CalApi {
|
||||
config = (namespaceOrConfig || {}) as Config;
|
||||
}
|
||||
|
||||
CalApi.initializedNamespaces.push(this.cal.namespace);
|
||||
|
||||
const { calOrigin: calOrigin, origin: origin, ...restConfig } = config;
|
||||
|
||||
this.cal.__config.calOrigin = calOrigin || origin || this.cal.__config.calOrigin;
|
||||
@@ -421,23 +448,35 @@ class CalApi {
|
||||
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
|
||||
throw new Error("iframeAttrs should be an object");
|
||||
}
|
||||
config.embedType = "inline";
|
||||
const iframe = this.cal.createIframe({ calLink, queryObject: Cal.getQueryObject(config) });
|
||||
iframe.style.height = "100%";
|
||||
iframe.style.width = "100%";
|
||||
const element =
|
||||
const containerEl =
|
||||
elementOrSelector instanceof HTMLElement
|
||||
? elementOrSelector
|
||||
: document.querySelector(elementOrSelector);
|
||||
if (!element) {
|
||||
|
||||
if (!containerEl) {
|
||||
throw new Error("Element not found");
|
||||
}
|
||||
element.classList.add("cal-inline-container");
|
||||
|
||||
config.embedType = "inline";
|
||||
|
||||
// Note that by the time, the server responds with html having appropriate color-scheme, there might be a flash of non-transparent(white/black) background(for a few 100 milliseconds)
|
||||
// There is no quick way know to fix it.
|
||||
// An approach might be to add a temporary neutral iframe(with no src) whose html can be directly modified synchronously and let the iframe with calLink src respond,
|
||||
// once the iframe responds, we can replace the neutral iframe with the calLink iframe
|
||||
const iframe = this.cal.createIframe({
|
||||
calLink,
|
||||
queryObject: withColorScheme(Cal.getQueryObject(config), containerEl),
|
||||
});
|
||||
|
||||
iframe.style.height = "100%";
|
||||
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>`;
|
||||
this.cal.inlineEl = template.content.children[0];
|
||||
this.cal.inlineEl.appendChild(iframe);
|
||||
element.appendChild(template.content);
|
||||
containerEl.appendChild(template.content);
|
||||
}
|
||||
|
||||
floatingButton({
|
||||
@@ -523,8 +562,14 @@ class CalApi {
|
||||
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
|
||||
throw new Error("iframeAttrs should be an object");
|
||||
}
|
||||
|
||||
config.embedType = "modal";
|
||||
const iframe = this.cal.createIframe({ calLink, calOrigin, queryObject: Cal.getQueryObject(config) });
|
||||
const containerEl = document.body;
|
||||
const iframe = this.cal.createIframe({
|
||||
calLink,
|
||||
queryObject: withColorScheme(Cal.getQueryObject(config), containerEl),
|
||||
});
|
||||
|
||||
iframe.style.borderRadius = "8px";
|
||||
|
||||
iframe.style.height = "100%";
|
||||
@@ -537,7 +582,7 @@ class CalApi {
|
||||
this.cal.actionManager.on("__closeIframe", () => {
|
||||
this.cal.modalBox.setAttribute("state", "closed");
|
||||
});
|
||||
document.body.appendChild(template.content);
|
||||
containerEl.appendChild(template.content);
|
||||
}
|
||||
|
||||
on<T extends keyof EventDataMap>({
|
||||
@@ -646,7 +691,9 @@ export interface CalWindow extends Window {
|
||||
Cal: GlobalCal;
|
||||
}
|
||||
|
||||
globalCal.instance = new Cal("", globalCal.q);
|
||||
const DEFAULT_NAMESPACE = "";
|
||||
|
||||
globalCal.instance = new Cal(DEFAULT_NAMESPACE, globalCal.q);
|
||||
for (const [ns, api] of Object.entries(globalCal.ns)) {
|
||||
api.instance = new Cal(ns, api.q);
|
||||
}
|
||||
@@ -711,3 +758,32 @@ document.addEventListener("click", (e) => {
|
||||
calOrigin,
|
||||
});
|
||||
});
|
||||
|
||||
let currentColorScheme: string | null = null;
|
||||
|
||||
(function watchAndActOnColorSchemeChange() {
|
||||
// TODO: Maybe find a better way to identify change in color-scheme, a mutation observer seems overkill for this. Settle with setInterval for now.
|
||||
setInterval(() => {
|
||||
const colorScheme = getColorScheme(document.body);
|
||||
if (colorScheme && colorScheme !== currentColorScheme) {
|
||||
currentColorScheme = colorScheme;
|
||||
// Go through all the embeds on the same page and update all of them with this info
|
||||
CalApi.initializedNamespaces.forEach((ns) => {
|
||||
const api = getEmbedApiFn(ns);
|
||||
api("ui", {
|
||||
colorScheme: colorScheme,
|
||||
});
|
||||
});
|
||||
}
|
||||
}, 50);
|
||||
})();
|
||||
|
||||
function getEmbedApiFn(ns: string) {
|
||||
let api;
|
||||
if (ns === DEFAULT_NAMESPACE) {
|
||||
api = globalCal;
|
||||
} else {
|
||||
api = globalCal.ns[ns];
|
||||
}
|
||||
return api;
|
||||
}
|
||||
Reference in new issue
Block a user