fix: ESC key handler only works once in embed modal (#25615)
* fix: ESC key handler only works once in embed modal Co-Authored-By: hariom@cal.com <hariombalhara@gmail.com> * Fix bug found by cubic and add tests --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
Devin AI
parent
0b7f51410a
commit
e23354b48b
@@ -9,6 +9,12 @@ import inlineHTML from "./Inline/inlineHtml";
|
||||
import { EMBED_DARK_THEME_CLASS, EMBED_LIGHT_THEME_CLASS } from "./constants";
|
||||
import { getColorSchemeDarkQuery } from "./ui-utils";
|
||||
|
||||
type EmbedElementWithPrivateMethodsAccess = {
|
||||
boundResizeHandler: () => void;
|
||||
boundPrefersDarkThemeChangedHandler: (e: MediaQueryListEvent) => void;
|
||||
boundEnsureContainerTakesSkeletonHeightWhenVisible: () => void;
|
||||
}
|
||||
|
||||
(function defineEmbedTestElement() {
|
||||
class TestEmbedElement extends EmbedElement {
|
||||
constructor({
|
||||
@@ -31,6 +37,75 @@ import { getColorSchemeDarkQuery } from "./ui-utils";
|
||||
customElements.define("test-embed", TestEmbedElement);
|
||||
})();
|
||||
|
||||
function mockWindowEventListeners() {
|
||||
const eventListenerCallbacks = new Map<string, EventListenerOrEventListenerObject>();
|
||||
const animationFrameCallbacks: Map<number, FrameRequestCallback> = new Map();
|
||||
const colorSchemeListenerCallbacks: Map<string, EventListenerOrEventListenerObject> = new Map();
|
||||
const colorSchemeQuery = getColorSchemeDarkQuery();
|
||||
let nextAnimationFrameId = 1;
|
||||
|
||||
vi
|
||||
.spyOn(window, "addEventListener")
|
||||
.mockImplementation((event: string, callback: EventListenerOrEventListenerObject) => {
|
||||
eventListenerCallbacks.set(event, callback);
|
||||
});
|
||||
|
||||
vi
|
||||
.spyOn(window, "removeEventListener")
|
||||
.mockImplementation((event: string, callback: EventListenerOrEventListenerObject) => {
|
||||
const registeredCallback = eventListenerCallbacks.get(event);
|
||||
expect(registeredCallback).toBe(callback);
|
||||
eventListenerCallbacks.delete(event);
|
||||
});
|
||||
|
||||
vi
|
||||
.spyOn(window, "requestAnimationFrame")
|
||||
.mockImplementation((callback: FrameRequestCallback) => {
|
||||
const id = nextAnimationFrameId++;
|
||||
animationFrameCallbacks.set(id, callback);
|
||||
return id;
|
||||
});
|
||||
|
||||
vi
|
||||
.spyOn(window, "cancelAnimationFrame")
|
||||
.mockImplementation((id: number) => {
|
||||
animationFrameCallbacks.delete(id);
|
||||
});
|
||||
|
||||
vi
|
||||
.spyOn(colorSchemeQuery, "addEventListener")
|
||||
.mockImplementation((event: string, callback: EventListenerOrEventListenerObject) => {
|
||||
colorSchemeListenerCallbacks.set(event, callback);
|
||||
});
|
||||
|
||||
vi
|
||||
.spyOn(colorSchemeQuery, "removeEventListener")
|
||||
.mockImplementation((event: string, callback: EventListenerOrEventListenerObject) => {
|
||||
colorSchemeListenerCallbacks.delete(event);
|
||||
});
|
||||
|
||||
return {
|
||||
expectListenerToBeRegistered: (event: string, callback: EventListenerOrEventListenerObject) => {
|
||||
expect(eventListenerCallbacks.get(event)).toBe(callback);
|
||||
},
|
||||
expectListenerToBeUnregistered: (event: string, callback: EventListenerOrEventListenerObject) => {
|
||||
expect(eventListenerCallbacks.get(event)).not.toBe(callback);
|
||||
},
|
||||
expectAnimationFrameListenerToBeRegistered: (rafId: number, callback: FrameRequestCallback) => {
|
||||
expect(animationFrameCallbacks.get(rafId)).toBe(callback);
|
||||
},
|
||||
expectAnimationFrameListenerToBeUnregistered: (rafId: number, callback: FrameRequestCallback) => {
|
||||
expect(animationFrameCallbacks.get(rafId)).not.toBe(callback);
|
||||
},
|
||||
expectColorSchemeListenerToBeRegistered: (callback: (e: MediaQueryListEvent) => void) => {
|
||||
expect(colorSchemeListenerCallbacks.get("change")).toBe(callback);
|
||||
},
|
||||
expectColorSchemeListenerToBeUnregistered: (callback: (e: MediaQueryListEvent) => void) => {
|
||||
expect(colorSchemeListenerCallbacks.get("change")).not.toBe(callback);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildMediaQueryListEvent({ type, matches }: { type: string; matches: boolean }) {
|
||||
return {
|
||||
type,
|
||||
@@ -143,7 +218,9 @@ describe("EmbedElement", () => {
|
||||
if (!element) {
|
||||
throw new Error("`element` not defined");
|
||||
}
|
||||
document.body.removeChild(element);
|
||||
if (element.parentNode) {
|
||||
document.body.removeChild(element);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
@@ -358,5 +435,33 @@ describe("EmbedElement", () => {
|
||||
expectLayoutToBe("month_view", element);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Cleanup Behavior", () => {
|
||||
it("should clean up all resources when element is disconnected", () => {
|
||||
const { expectListenerToBeRegistered, expectListenerToBeUnregistered, expectAnimationFrameListenerToBeRegistered, expectAnimationFrameListenerToBeUnregistered, expectColorSchemeListenerToBeRegistered, expectColorSchemeListenerToBeUnregistered } = mockWindowEventListeners();
|
||||
|
||||
element = createTestEmbedElement({
|
||||
dataset: { pageType: "user.event.booking.slots" },
|
||||
});
|
||||
|
||||
|
||||
|
||||
const internalEmbed = element as unknown as EmbedElementWithPrivateMethodsAccess;
|
||||
|
||||
const boundResizeHandler = internalEmbed.boundResizeHandler;
|
||||
const boundPrefersDarkThemeChangedHandler = internalEmbed.boundPrefersDarkThemeChangedHandler;
|
||||
const boundEnsureContainerTakesSkeletonHeightWhenVisible = internalEmbed.boundEnsureContainerTakesSkeletonHeightWhenVisible;
|
||||
|
||||
expectListenerToBeRegistered("resize", boundResizeHandler);
|
||||
expectColorSchemeListenerToBeRegistered(boundPrefersDarkThemeChangedHandler);
|
||||
expectAnimationFrameListenerToBeRegistered(element.skeletonContainerHeightTimer!, boundEnsureContainerTakesSkeletonHeightWhenVisible);
|
||||
|
||||
document.body.removeChild(element);
|
||||
|
||||
expectListenerToBeUnregistered("resize", boundResizeHandler);
|
||||
expectColorSchemeListenerToBeUnregistered(boundPrefersDarkThemeChangedHandler);
|
||||
expectAnimationFrameListenerToBeUnregistered(element.skeletonContainerHeightTimer!, boundEnsureContainerTakesSkeletonHeightWhenVisible);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,8 @@ export class EmbedElement extends HTMLElement {
|
||||
|
||||
private boundResizeHandler: () => void;
|
||||
private boundPrefersDarkThemeChangedHandler: (e: MediaQueryListEvent) => void;
|
||||
private boundEnsureContainerTakesSkeletonHeightWhenVisible: () => void;
|
||||
|
||||
private isSkeletonSupportedPageType() {
|
||||
const pageType = this.getPageType();
|
||||
// Any pageType being set is considered as skeleton supported. There is always a fallback skeleton loader if no direct match for a skeleton loader is found based on pageType
|
||||
@@ -99,7 +101,7 @@ export class EmbedElement extends HTMLElement {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const rafId = requestAnimationFrame(this.ensureContainerTakesSkeletonHeightWhenVisible.bind(this));
|
||||
const rafId = requestAnimationFrame(this.boundEnsureContainerTakesSkeletonHeightWhenVisible);
|
||||
this.skeletonContainerHeightTimer = rafId;
|
||||
return rafId;
|
||||
}
|
||||
@@ -158,6 +160,7 @@ export class EmbedElement extends HTMLElement {
|
||||
this.getSkeletonData = data.getSkeletonData;
|
||||
this.boundResizeHandler = this.resizeHandler.bind(this);
|
||||
this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this);
|
||||
this.boundEnsureContainerTakesSkeletonHeightWhenVisible = this.ensureContainerTakesSkeletonHeightWhenVisible.bind(this);
|
||||
}
|
||||
|
||||
public isSkeletonLoaderVisible() {
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import "../../test/__mocks__/windowMatchMedia";
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
|
||||
import { EmbedElement } from "../EmbedElement";
|
||||
import { ModalBox } from "./ModalBox";
|
||||
|
||||
function setupWindowCal() {
|
||||
// Mock window.Cal.__css which is required by ModalBox constructor
|
||||
if (!window.Cal) {
|
||||
// @ts-expect-error - Test setup
|
||||
window.Cal = {};
|
||||
}
|
||||
window.Cal.__css = "/* mock css */";
|
||||
}
|
||||
|
||||
// Register ModalBox as a custom element for testing
|
||||
if (!customElements.get("cal-modal-box")) {
|
||||
customElements.define("cal-modal-box", ModalBox);
|
||||
}
|
||||
|
||||
describe("ModalBox", () => {
|
||||
let modalBox: ModalBox;
|
||||
|
||||
beforeEach(() => {
|
||||
setupWindowCal();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (modalBox && modalBox.parentNode) {
|
||||
document.body.removeChild(modalBox);
|
||||
}
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("should verify that connectedCallback and disconnectedCallback of ModalBox call the EmbedElement's methods", () => {
|
||||
const embedElementConnectedCallbackSpy = vi.spyOn(EmbedElement.prototype, "connectedCallback");
|
||||
const embedElementDisconnectedCallbackSpy = vi.spyOn(EmbedElement.prototype, "disconnectedCallback");
|
||||
|
||||
modalBox = new ModalBox();
|
||||
|
||||
document.body.appendChild(modalBox);
|
||||
|
||||
expect(embedElementConnectedCallbackSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
document.body.removeChild(modalBox);
|
||||
|
||||
expect(embedElementDisconnectedCallbackSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -6,6 +6,12 @@ import modalBoxHtml, { getSkeletonData } from "./ModalBoxHtml";
|
||||
export class ModalBox extends EmbedElement {
|
||||
static htmlOverflow: string;
|
||||
|
||||
private escHandler = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") {
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
static get observedAttributes() {
|
||||
@@ -220,17 +226,7 @@ export class ModalBox extends EmbedElement {
|
||||
super.connectedCallback();
|
||||
this.assertHasShadowRoot();
|
||||
const closeEl = this.shadowRoot.querySelector<HTMLElement>(".close");
|
||||
document.addEventListener(
|
||||
"keydown",
|
||||
(e) => {
|
||||
if (e.key === "Escape") {
|
||||
this.close();
|
||||
}
|
||||
},
|
||||
{
|
||||
once: true,
|
||||
}
|
||||
);
|
||||
document.addEventListener("keydown", this.escHandler);
|
||||
|
||||
// The backdrop is inside the host element, and a click on host element is only possible if the user clicks outside the iframe.
|
||||
// So, it is backdrop click handler
|
||||
@@ -245,6 +241,11 @@ export class ModalBox extends EmbedElement {
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.removeEventListener("keydown", this.escHandler);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super({ isModal: true, getSkeletonData });
|
||||
const modalHtml = `<style>${window.Cal.__css}</style><style>${loaderCss}</style>${modalBoxHtml({
|
||||
|
||||
Reference in New Issue
Block a user