diff --git a/packages/calcite-components/src/components/modal/modal.e2e.ts b/packages/calcite-components/src/components/modal/modal.e2e.ts index eea592cbe82..6f97b2481e4 100644 --- a/packages/calcite-components/src/components/modal/modal.e2e.ts +++ b/packages/calcite-components/src/components/modal/modal.e2e.ts @@ -1,10 +1,10 @@ -import { newE2EPage } from "@stencil/core/testing"; +import { E2EPage, newE2EPage } from "@stencil/core/testing"; import { focusable, hidden, openClose, renders, slots, t9n } from "../../tests/commonTests"; import { html } from "../../../support/formatting"; import { CSS, SLOTS } from "./resources"; import { GlobalTestProps, isElementFocused, newProgrammaticE2EPage, skipAnimations } from "../../tests/utils"; -describe("calcite-modal properties", () => { +describe("calcite-modal", () => { describe("renders", () => { renders("calcite-modal", { display: "flex", visible: false }); }); @@ -26,6 +26,10 @@ describe("calcite-modal properties", () => { slots("calcite-modal", SLOTS); }); + describe("translation support", () => { + t9n("calcite-modal"); + }); + it("should hide closeButton when disabled", async () => { const page = await newE2EPage(); await page.setContent(""); @@ -169,363 +173,363 @@ describe("calcite-modal properties", () => { expect(mockCallBack).toHaveBeenCalledTimes(1); expect(await modal.getProperty("opened")).toBe(false); }); -}); -it("calls the beforeClose method prior to closing via attribute", async () => { - const page = await newE2EPage(); - const mockCallBack = jest.fn(); - await page.exposeFunction("beforeClose", mockCallBack); - await page.setContent(` + it("calls the beforeClose method prior to closing via attribute", async () => { + const page = await newE2EPage(); + const mockCallBack = jest.fn(); + await page.exposeFunction("beforeClose", mockCallBack); + await page.setContent(` `); - const modal = await page.find("calcite-modal"); - await page.$eval( - "calcite-modal", - (el: HTMLCalciteModalElement) => - (el.beforeClose = ( - window as GlobalTestProps<{ beforeClose: HTMLCalciteModalElement["beforeClose"] }> - ).beforeClose) - ); - await page.waitForChanges(); - modal.setProperty("open", true); - await page.waitForChanges(); - expect(await modal.getProperty("opened")).toBe(true); - modal.removeAttribute("open"); - await page.waitForChanges(); - expect(mockCallBack).toHaveBeenCalledTimes(1); - expect(await modal.getProperty("opened")).toBe(false); -}); - -it("should handle rejected 'beforeClose' promise'", async () => { - const page = await newE2EPage(); - - const mockCallBack = jest.fn().mockReturnValue(() => Promise.reject()); - await page.exposeFunction("beforeClose", mockCallBack); + const modal = await page.find("calcite-modal"); + await page.$eval( + "calcite-modal", + (el: HTMLCalciteModalElement) => + (el.beforeClose = ( + window as GlobalTestProps<{ beforeClose: HTMLCalciteModalElement["beforeClose"] }> + ).beforeClose) + ); + await page.waitForChanges(); + modal.setProperty("open", true); + await page.waitForChanges(); + expect(await modal.getProperty("opened")).toBe(true); + modal.removeAttribute("open"); + await page.waitForChanges(); + expect(mockCallBack).toHaveBeenCalledTimes(1); + expect(await modal.getProperty("opened")).toBe(false); + }); - await page.setContent(``); + it("should handle rejected 'beforeClose' promise'", async () => { + const page = await newE2EPage(); - await page.$eval( - "calcite-modal", - (elm: HTMLCalciteModalElement) => - (elm.beforeClose = (window as typeof window & Pick).beforeClose) - ); + const mockCallBack = jest.fn().mockReturnValue(() => Promise.reject()); + await page.exposeFunction("beforeClose", mockCallBack); - const modal = await page.find("calcite-modal"); - modal.setProperty("open", false); - await page.waitForChanges(); + await page.setContent(``); - expect(mockCallBack).toHaveBeenCalledTimes(1); -}); + await page.$eval( + "calcite-modal", + (elm: HTMLCalciteModalElement) => + (elm.beforeClose = (window as typeof window & Pick).beforeClose) + ); -it("should remain open with rejected 'beforeClose' promise'", async () => { - const page = await newE2EPage(); + const modal = await page.find("calcite-modal"); + modal.setProperty("open", false); + await page.waitForChanges(); - await page.exposeFunction("beforeClose", () => Promise.reject()); - await page.setContent(``); + expect(mockCallBack).toHaveBeenCalledTimes(1); + }); - await page.$eval( - "calcite-modal", - (elm: HTMLCalciteModalElement) => - (elm.beforeClose = (window as typeof window & Pick).beforeClose) - ); + it("should remain open with rejected 'beforeClose' promise'", async () => { + const page = await newE2EPage(); - const modal = await page.find("calcite-modal"); - modal.setProperty("open", false); - await page.waitForChanges(); + await page.exposeFunction("beforeClose", () => Promise.reject()); + await page.setContent(``); - expect(await modal.getProperty("open")).toBe(true); - expect(await modal.getProperty("opened")).toBe(true); - expect(modal.getAttribute("open")).toBe(""); // Makes sure attribute is added back -}); + await page.$eval( + "calcite-modal", + (elm: HTMLCalciteModalElement) => + (elm.beforeClose = (window as typeof window & Pick).beforeClose) + ); -describe("opening and closing behavior", () => { - it("opens and closes", async () => { - const page = await newE2EPage(); - await page.setContent(html``); const modal = await page.find("calcite-modal"); + modal.setProperty("open", false); + await page.waitForChanges(); - type ModalEventOrderWindow = GlobalTestProps<{ events: string[] }>; - - await page.$eval("calcite-modal", (modal: HTMLCalciteModalElement) => { - const receivedEvents: string[] = []; - (window as ModalEventOrderWindow).events = receivedEvents; + expect(await modal.getProperty("open")).toBe(true); + expect(await modal.getProperty("opened")).toBe(true); + expect(modal.getAttribute("open")).toBe(""); // Makes sure attribute is added back + }); - ["calciteModalBeforeOpen", "calciteModalOpen", "calciteModalBeforeClose", "calciteModalClose"].forEach( - (eventType) => { - modal.addEventListener(eventType, (event) => receivedEvents.push(event.type)); - } - ); - }); + describe("opening and closing behavior", () => { + it("opens and closes", async () => { + const page = await newE2EPage(); + await page.setContent(html``); + const modal = await page.find("calcite-modal"); - const beforeOpenSpy = await modal.spyOnEvent("calciteModalBeforeOpen"); - const openSpy = await modal.spyOnEvent("calciteModalOpen"); - const beforeCloseSpy = await modal.spyOnEvent("calciteModalBeforeClose"); - const closeSpy = await modal.spyOnEvent("calciteModalClose"); + type ModalEventOrderWindow = GlobalTestProps<{ events: string[] }>; - expect(beforeOpenSpy).toHaveReceivedEventTimes(0); - expect(openSpy).toHaveReceivedEventTimes(0); - expect(beforeCloseSpy).toHaveReceivedEventTimes(0); - expect(closeSpy).toHaveReceivedEventTimes(0); + await page.$eval("calcite-modal", (modal: HTMLCalciteModalElement) => { + const receivedEvents: string[] = []; + (window as ModalEventOrderWindow).events = receivedEvents; - expect(await modal.isVisible()).toBe(false); - - const modalBeforeOpen = page.waitForEvent("calciteModalBeforeOpen"); - const modalOpen = page.waitForEvent("calciteModalOpen"); - await modal.setProperty("open", true); - await page.waitForChanges(); + ["calciteModalBeforeOpen", "calciteModalOpen", "calciteModalBeforeClose", "calciteModalClose"].forEach( + (eventType) => { + modal.addEventListener(eventType, (event) => receivedEvents.push(event.type)); + } + ); + }); - await modalBeforeOpen; - await modalOpen; + const beforeOpenSpy = await modal.spyOnEvent("calciteModalBeforeOpen"); + const openSpy = await modal.spyOnEvent("calciteModalOpen"); + const beforeCloseSpy = await modal.spyOnEvent("calciteModalBeforeClose"); + const closeSpy = await modal.spyOnEvent("calciteModalClose"); - expect(beforeOpenSpy).toHaveReceivedEventTimes(1); - expect(openSpy).toHaveReceivedEventTimes(1); - expect(beforeCloseSpy).toHaveReceivedEventTimes(0); - expect(closeSpy).toHaveReceivedEventTimes(0); + expect(beforeOpenSpy).toHaveReceivedEventTimes(0); + expect(openSpy).toHaveReceivedEventTimes(0); + expect(beforeCloseSpy).toHaveReceivedEventTimes(0); + expect(closeSpy).toHaveReceivedEventTimes(0); - expect(await modal.isVisible()).toBe(true); + expect(await modal.isVisible()).toBe(false); - const modalBeforeClose = page.waitForEvent("calciteModalBeforeClose"); - const modalClose = page.waitForEvent("calciteModalClose"); - await modal.setProperty("open", false); - await page.waitForChanges(); + const modalBeforeOpen = page.waitForEvent("calciteModalBeforeOpen"); + const modalOpen = page.waitForEvent("calciteModalOpen"); + await modal.setProperty("open", true); + await page.waitForChanges(); - await modalBeforeClose; - await modalClose; + await modalBeforeOpen; + await modalOpen; - expect(beforeOpenSpy).toHaveReceivedEventTimes(1); - expect(openSpy).toHaveReceivedEventTimes(1); - expect(beforeCloseSpy).toHaveReceivedEventTimes(1); - expect(closeSpy).toHaveReceivedEventTimes(1); + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); + expect(beforeCloseSpy).toHaveReceivedEventTimes(0); + expect(closeSpy).toHaveReceivedEventTimes(0); - expect(await modal.isVisible()).toBe(false); + expect(await modal.isVisible()).toBe(true); - expect(await page.evaluate(() => (window as ModalEventOrderWindow).events)).toEqual([ - "calciteModalBeforeOpen", - "calciteModalOpen", - "calciteModalBeforeClose", - "calciteModalClose", - ]); - }); + const modalBeforeClose = page.waitForEvent("calciteModalBeforeClose"); + const modalClose = page.waitForEvent("calciteModalClose"); + await modal.setProperty("open", false); + await page.waitForChanges(); - it("emits when closing on click", async () => { - const page = await newE2EPage(); - await page.setContent(html``); - const modal = await page.find("calcite-modal"); + await modalBeforeClose; + await modalClose; - const beforeOpenSpy = await modal.spyOnEvent("calciteModalBeforeOpen"); - const openSpy = await modal.spyOnEvent("calciteModalOpen"); - const beforeCloseSpy = await modal.spyOnEvent("calciteModalBeforeClose"); - const closeSpy = await modal.spyOnEvent("calciteModalClose"); + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); + expect(beforeCloseSpy).toHaveReceivedEventTimes(1); + expect(closeSpy).toHaveReceivedEventTimes(1); - expect(beforeOpenSpy).toHaveReceivedEventTimes(0); - expect(openSpy).toHaveReceivedEventTimes(0); - expect(beforeCloseSpy).toHaveReceivedEventTimes(0); - expect(closeSpy).toHaveReceivedEventTimes(0); + expect(await modal.isVisible()).toBe(false); - expect(await modal.isVisible()).toBe(false); + expect(await page.evaluate(() => (window as ModalEventOrderWindow).events)).toEqual([ + "calciteModalBeforeOpen", + "calciteModalOpen", + "calciteModalBeforeClose", + "calciteModalClose", + ]); + }); - const modalBeforeOpen = page.waitForEvent("calciteModalBeforeOpen"); - const modalOpen = page.waitForEvent("calciteModalOpen"); - modal.setProperty("open", true); - await page.waitForChanges(); + it("emits when closing on click", async () => { + const page = await newE2EPage(); + await page.setContent(html``); + const modal = await page.find("calcite-modal"); - await modalBeforeOpen; - await modalOpen; + const beforeOpenSpy = await modal.spyOnEvent("calciteModalBeforeOpen"); + const openSpy = await modal.spyOnEvent("calciteModalOpen"); + const beforeCloseSpy = await modal.spyOnEvent("calciteModalBeforeClose"); + const closeSpy = await modal.spyOnEvent("calciteModalClose"); - expect(beforeOpenSpy).toHaveReceivedEventTimes(1); - expect(openSpy).toHaveReceivedEventTimes(1); - expect(beforeCloseSpy).toHaveReceivedEventTimes(0); - expect(closeSpy).toHaveReceivedEventTimes(0); + expect(beforeOpenSpy).toHaveReceivedEventTimes(0); + expect(openSpy).toHaveReceivedEventTimes(0); + expect(beforeCloseSpy).toHaveReceivedEventTimes(0); + expect(closeSpy).toHaveReceivedEventTimes(0); - expect(await modal.isVisible()).toBe(true); + expect(await modal.isVisible()).toBe(false); - const modalBeforeClose = page.waitForEvent("calciteModalBeforeClose"); - const modalClose = page.waitForEvent("calciteModalClose"); - const closeButton = await page.find(`calcite-modal >>> .${CSS.close}`); - await closeButton.click(); - await page.waitForChanges(); + const modalBeforeOpen = page.waitForEvent("calciteModalBeforeOpen"); + const modalOpen = page.waitForEvent("calciteModalOpen"); + modal.setProperty("open", true); + await page.waitForChanges(); - await modalBeforeClose; - await modalClose; + await modalBeforeOpen; + await modalOpen; - expect(beforeOpenSpy).toHaveReceivedEventTimes(1); - expect(openSpy).toHaveReceivedEventTimes(1); - expect(beforeCloseSpy).toHaveReceivedEventTimes(1); - expect(closeSpy).toHaveReceivedEventTimes(1); + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); + expect(beforeCloseSpy).toHaveReceivedEventTimes(0); + expect(closeSpy).toHaveReceivedEventTimes(0); - expect(await modal.isVisible()).toBe(false); - }); + expect(await modal.isVisible()).toBe(true); - it("emits when set to open on initial render", async () => { - const page = await newProgrammaticE2EPage(); + const modalBeforeClose = page.waitForEvent("calciteModalBeforeClose"); + const modalClose = page.waitForEvent("calciteModalClose"); + const closeButton = await page.find(`calcite-modal >>> .${CSS.close}`); + await closeButton.click(); + await page.waitForChanges(); - const beforeOpenSpy = await page.spyOnEvent("calciteModalBeforeOpen"); - const openSpy = await page.spyOnEvent("calciteModalOpen"); + await modalBeforeClose; + await modalClose; - const waitForBeforeOpenEvent = page.waitForEvent("calciteModalBeforeOpen"); - const waitForOpenEvent = page.waitForEvent("calciteModalOpen"); + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); + expect(beforeCloseSpy).toHaveReceivedEventTimes(1); + expect(closeSpy).toHaveReceivedEventTimes(1); - await page.evaluate((): void => { - const modal = document.createElement("calcite-modal"); - modal.open = true; - document.body.append(modal); + expect(await modal.isVisible()).toBe(false); }); - await page.waitForChanges(); - await waitForBeforeOpenEvent; - await waitForOpenEvent; + it("emits when set to open on initial render", async () => { + const page = await newProgrammaticE2EPage(); - expect(beforeOpenSpy).toHaveReceivedEventTimes(1); - expect(openSpy).toHaveReceivedEventTimes(1); - }); + const beforeOpenSpy = await page.spyOnEvent("calciteModalBeforeOpen"); + const openSpy = await page.spyOnEvent("calciteModalOpen"); - it("emits when set to open on initial render and duration is 0", async () => { - const page = await newProgrammaticE2EPage(); - await skipAnimations(page); + const waitForBeforeOpenEvent = page.waitForEvent("calciteModalBeforeOpen"); + const waitForOpenEvent = page.waitForEvent("calciteModalOpen"); - const beforeOpenSpy = await page.spyOnEvent("calciteModalBeforeOpen"); - const openSpy = await page.spyOnEvent("calciteModalOpen"); + await page.evaluate((): void => { + const modal = document.createElement("calcite-modal"); + modal.open = true; + document.body.append(modal); + }); - const waitForOpenEvent = page.waitForEvent("calciteModalOpen"); - const waitForBeforeOpenEvent = page.waitForEvent("calciteModalBeforeOpen"); + await page.waitForChanges(); + await waitForBeforeOpenEvent; + await waitForOpenEvent; - await page.evaluate((): void => { - const modal = document.createElement("calcite-modal"); - modal.open = true; - document.body.append(modal); + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); }); - await page.waitForChanges(); - await waitForBeforeOpenEvent; - await waitForOpenEvent; + it("emits when set to open on initial render and duration is 0", async () => { + const page = await newProgrammaticE2EPage(); + await skipAnimations(page); - expect(beforeOpenSpy).toHaveReceivedEventTimes(1); - expect(openSpy).toHaveReceivedEventTimes(1); - }); + const beforeOpenSpy = await page.spyOnEvent("calciteModalBeforeOpen"); + const openSpy = await page.spyOnEvent("calciteModalOpen"); - it("emits when duration is set to 0", async () => { - const page = await newProgrammaticE2EPage(); - await skipAnimations(page); + const waitForOpenEvent = page.waitForEvent("calciteModalOpen"); + const waitForBeforeOpenEvent = page.waitForEvent("calciteModalBeforeOpen"); - const beforeOpenSpy = await page.spyOnEvent("calciteModalBeforeOpen"); - const openSpy = await page.spyOnEvent("calciteModalOpen"); + await page.evaluate((): void => { + const modal = document.createElement("calcite-modal"); + modal.open = true; + document.body.append(modal); + }); - const beforeCloseSpy = await page.spyOnEvent("calciteModalBeforeClose"); - const closeSpy = await page.spyOnEvent("calciteModalClose"); + await page.waitForChanges(); + await waitForBeforeOpenEvent; + await waitForOpenEvent; - await page.evaluate((): void => { - const modal = document.createElement("calcite-modal"); - modal.open = true; - document.body.append(modal); + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); }); - await page.waitForChanges(); - await beforeOpenSpy; - await openSpy; - - expect(beforeOpenSpy).toHaveReceivedEventTimes(1); - expect(openSpy).toHaveReceivedEventTimes(1); + it("emits when duration is set to 0", async () => { + const page = await newProgrammaticE2EPage(); + await skipAnimations(page); - await page.evaluate(() => { - const modal = document.querySelector("calcite-modal"); - modal.open = false; - }); + const beforeOpenSpy = await page.spyOnEvent("calciteModalBeforeOpen"); + const openSpy = await page.spyOnEvent("calciteModalOpen"); - await page.waitForChanges(); - await beforeCloseSpy; - await closeSpy; + const beforeCloseSpy = await page.spyOnEvent("calciteModalBeforeClose"); + const closeSpy = await page.spyOnEvent("calciteModalClose"); - expect(beforeCloseSpy).toHaveReceivedEventTimes(1); - expect(closeSpy).toHaveReceivedEventTimes(1); - }); -}); + await page.evaluate((): void => { + const modal = document.createElement("calcite-modal"); + modal.open = true; + document.body.append(modal); + }); -describe("calcite-modal accessibility checks", () => { - it("traps focus within the modal when open", async () => { - const button1Id = "button1"; - const button2Id = "button2"; - const page = await newE2EPage(); - await page.setContent( - html` -
- - -
-
` - ); - const modal = await page.find("calcite-modal"); - const opened = page.waitForEvent("calciteModalOpen"); - modal.setProperty("open", true); - await page.waitForChanges(); - await opened; + await page.waitForChanges(); + await beforeOpenSpy; + await openSpy; - expect(await isElementFocused(page, `.${CSS.close}`, { shadowed: true })).toBe(true); - await page.keyboard.press("Tab"); - expect(await isElementFocused(page, `#${button1Id}`)).toBe(true); - await page.keyboard.press("Tab"); - expect(await isElementFocused(page, `#${button2Id}`)).toBe(true); + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); - await page.keyboard.press("Tab"); - expect(await isElementFocused(page, `.${CSS.close}`, { shadowed: true })).toBe(true); - await page.keyboard.down("Shift"); - await page.keyboard.press("Tab"); - expect(await isElementFocused(page, `#${button2Id}`)).toBe(true); + await page.evaluate(() => { + const modal = document.querySelector("calcite-modal"); + modal.open = false; + }); - await page.keyboard.press("Tab"); - expect(await isElementFocused(page, `#${button1Id}`)).toBe(true); - }); + await page.waitForChanges(); + await beforeCloseSpy; + await closeSpy; - it("restores focus to previously focused element when closed", async () => { - const initiallyFocusedId = "initially-focused"; - const initiallyFocusedIdSelector = `#${initiallyFocusedId}`; - const page = await newE2EPage(); - await page.setContent( - html` - - - ` - ); - await skipAnimations(page); - const modal = await page.find("calcite-modal"); - await page.$eval(initiallyFocusedIdSelector, (button: HTMLButtonElement) => { - button.focus(); + expect(beforeCloseSpy).toHaveReceivedEventTimes(1); + expect(closeSpy).toHaveReceivedEventTimes(1); }); - await modal.setProperty("open", true); - await page.waitForChanges(); - await modal.setProperty("open", false); - await page.waitForChanges(); - expect(await isElementFocused(page, initiallyFocusedIdSelector)).toBe(true); }); - it("traps focus within the modal when open and disabled close button", async () => { - const button1Id = "button1"; - const button2Id = "button2"; - const page = await newE2EPage(); - await page.setContent( - html` -
- - -
-
` - ); - await skipAnimations(page); - const modal = await page.find("calcite-modal"); - - await modal.setProperty("open", true); - await page.waitForChanges(); - expect(await isElementFocused(page, `#${button1Id}`)).toBe(true); + describe("calcite-modal accessibility checks", () => { + it("traps focus within the modal when open", async () => { + const button1Id = "button1"; + const button2Id = "button2"; + const page = await newE2EPage(); + await page.setContent( + html` +
+ + +
+
` + ); + const modal = await page.find("calcite-modal"); + const opened = page.waitForEvent("calciteModalOpen"); + modal.setProperty("open", true); + await page.waitForChanges(); + await opened; + + expect(await isElementFocused(page, `.${CSS.close}`, { shadowed: true })).toBe(true); + await page.keyboard.press("Tab"); + expect(await isElementFocused(page, `#${button1Id}`)).toBe(true); + await page.keyboard.press("Tab"); + expect(await isElementFocused(page, `#${button2Id}`)).toBe(true); + + await page.keyboard.press("Tab"); + expect(await isElementFocused(page, `.${CSS.close}`, { shadowed: true })).toBe(true); + await page.keyboard.down("Shift"); + await page.keyboard.press("Tab"); + expect(await isElementFocused(page, `#${button2Id}`)).toBe(true); + + await page.keyboard.press("Tab"); + expect(await isElementFocused(page, `#${button1Id}`)).toBe(true); + }); - await page.keyboard.press("Tab"); - expect(await isElementFocused(page, `#${button2Id}`)).toBe(true); + it("restores focus to previously focused element when closed", async () => { + const initiallyFocusedId = "initially-focused"; + const initiallyFocusedIdSelector = `#${initiallyFocusedId}`; + const page = await newE2EPage(); + await page.setContent( + html` + + + ` + ); + await skipAnimations(page); + const modal = await page.find("calcite-modal"); + await page.$eval(initiallyFocusedIdSelector, (button: HTMLButtonElement) => { + button.focus(); + }); + await modal.setProperty("open", true); + await page.waitForChanges(); + await modal.setProperty("open", false); + await page.waitForChanges(); + expect(await isElementFocused(page, initiallyFocusedIdSelector)).toBe(true); + }); - await page.keyboard.press("Tab"); - expect(await isElementFocused(page, `#${button1Id}`)).toBe(true); - await page.keyboard.down("Shift"); - await page.keyboard.press("Tab"); - expect(await isElementFocused(page, `#${button2Id}`)).toBe(true); - await page.keyboard.press("Tab"); - expect(await isElementFocused(page, `#${button1Id}`)).toBe(true); + it("traps focus within the modal when open and disabled close button", async () => { + const button1Id = "button1"; + const button2Id = "button2"; + const page = await newE2EPage(); + await page.setContent( + html` +
+ + +
+
` + ); + await skipAnimations(page); + const modal = await page.find("calcite-modal"); + + await modal.setProperty("open", true); + await page.waitForChanges(); + expect(await isElementFocused(page, `#${button1Id}`)).toBe(true); + + await page.keyboard.press("Tab"); + expect(await isElementFocused(page, `#${button2Id}`)).toBe(true); + + await page.keyboard.press("Tab"); + expect(await isElementFocused(page, `#${button1Id}`)).toBe(true); + await page.keyboard.down("Shift"); + await page.keyboard.press("Tab"); + expect(await isElementFocused(page, `#${button2Id}`)).toBe(true); + await page.keyboard.press("Tab"); + expect(await isElementFocused(page, `#${button1Id}`)).toBe(true); + }); }); describe("setFocus", () => { @@ -595,7 +599,7 @@ describe("calcite-modal accessibility checks", () => { it("closes and allows re-opening when Close button is clicked", async () => { const page = await newE2EPage(); - await page.setContent(``); + await page.setContent(``); await skipAnimations(page); const modal = await page.find("calcite-modal"); modal.setProperty("open", true); @@ -647,42 +651,102 @@ describe("calcite-modal accessibility checks", () => { expect(modal).toHaveAttribute("open"); }); - it("correctly adds overflow class on document when open", async () => { - const page = await newE2EPage(); - await page.setContent(``); - const modal = await page.find("calcite-modal"); - await modal.setProperty("open", true); - await page.waitForChanges(); - const isOverflowHidden = await page.evaluate(() => { - return document.documentElement.style.overflow === "hidden"; + describe("overflow prevention", () => { + async function hasOverflowStyle(page: E2EPage): Promise { + return page.evaluate(() => document.documentElement.style.overflow === "hidden"); + } + + it("correctly sets overflow style on document when opened/closed", async () => { + const page = await newE2EPage(); + await page.setContent(``); + const modal = await page.find("calcite-modal"); + + await modal.setProperty("open", true); + await page.waitForChanges(); + + expect(await hasOverflowStyle(page)).toEqual(true); + + await modal.setProperty("open", false); + await page.waitForChanges(); + + expect(await hasOverflowStyle(page)).toEqual(false); }); - expect(isOverflowHidden).toEqual(true); - }); - it("correctly does not add overflow class on document when open and slotted in shell modals slot", async () => { - const page = await newE2EPage(); - await page.setContent(``); - const modal = await page.find("calcite-modal"); - await modal.setProperty("open", true); - await page.waitForChanges(); - const isOverflowHidden = await page.evaluate(() => { - return document.documentElement.style.overflow === "hidden"; + it("preserves existing overflow style when modal is opened/closed", async () => { + const page = await newE2EPage(); + await page.setContent(``); + await page.evaluate(() => (document.documentElement.style.overflow = "scroll")); + const modal = await page.find("calcite-modal"); + + await modal.setProperty("open", true); + await page.waitForChanges(); + + expect(await hasOverflowStyle(page)).toEqual(true); + + await modal.setProperty("open", false); + await page.waitForChanges(); + + expect(await page.evaluate(() => document.documentElement.style.overflow)).toEqual("scroll"); }); - expect(isOverflowHidden).toEqual(false); - }); - it("correctly removes overflow class on document once closed", async () => { - const page = await newE2EPage(); - await page.setContent(``); - const modal = await page.find("calcite-modal"); - await modal.setProperty("open", true); - await page.waitForChanges(); - await modal.setProperty("open", false); - await page.waitForChanges(); - const documentClass = await page.evaluate(() => { - return document.documentElement.classList.contains("overflow-hidden"); + it("correctly does not add overflow style on document when open and slotted in shell modals slot", async () => { + const page = await newE2EPage(); + await page.setContent(``); + const modal = await page.find("calcite-modal"); + + await modal.setProperty("open", true); + await page.waitForChanges(); + + expect(await hasOverflowStyle(page)).toEqual(false); + }); + + it("correctly removes overflow style on document when multiple modals are closed in first-in-last-out order", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + `); + const modal1 = await page.find("#modal-1"); + const modal2 = await page.find("#modal-2"); + + await modal1.setProperty("open", true); + await page.waitForChanges(); + await modal2.setProperty("open", true); + await page.waitForChanges(); + + expect(await hasOverflowStyle(page)).toEqual(true); + + await modal2.setProperty("open", false); + await page.waitForChanges(); + await modal1.setProperty("open", false); + await page.waitForChanges(); + + expect(await hasOverflowStyle(page)).toEqual(false); + }); + + it("correctly removes overflow style on document when multiple modals are closed in first-in-first-out order", async () => { + const page = await newE2EPage(); + await page.setContent(html` + + + `); + const modal1 = await page.find("#modal-1"); + const modal2 = await page.find("#modal-2"); + + await modal1.setProperty("open", true); + await page.waitForChanges(); + await modal2.setProperty("open", true); + await page.waitForChanges(); + + expect(await hasOverflowStyle(page)).toEqual(true); + + await modal1.setProperty("open", false); + await page.waitForChanges(); + await modal2.setProperty("open", false); + await page.waitForChanges(); + + expect(await hasOverflowStyle(page)).toEqual(false); }); - expect(documentClass).toEqual(false); }); it("renders correctly with no footer", async () => { @@ -762,8 +826,4 @@ describe("calcite-modal accessibility checks", () => { closeIcon = await page.find('calcite-modal >>> calcite-icon[scale="m"]'); expect(closeIcon).not.toBe(null); }); - - describe("translation support", () => { - t9n("calcite-modal"); - }); }); diff --git a/packages/calcite-components/src/components/modal/modal.tsx b/packages/calcite-components/src/components/modal/modal.tsx index e05d3b1d1d2..e5b0877592c 100644 --- a/packages/calcite-components/src/components/modal/modal.tsx +++ b/packages/calcite-components/src/components/modal/modal.tsx @@ -54,6 +54,9 @@ import { ModalMessages } from "./assets/modal/t9n"; import { getIconScale } from "../../utils/component"; +let totalOpenModals: number = 0; +let initialDocumentOverflowStyle: string = ""; + /** * @slot header - A slot for adding header text. * @slot content - A slot for adding the component's content. @@ -539,7 +542,11 @@ export class Modal this.contentId = ensureId(contentEl); if (!this.slottedInShell) { - this.initialOverflowCSS = document.documentElement.style.overflow; + if (totalOpenModals === 0) { + initialDocumentOverflowStyle = document.documentElement.style.overflow; + } + + totalOpenModals++; // use an inline style instead of a utility class to avoid global class declarations. document.documentElement.style.setProperty("overflow", "hidden"); } @@ -568,12 +575,13 @@ export class Modal } } + totalOpenModals--; this.opened = false; this.removeOverflowHiddenClass(); }; private removeOverflowHiddenClass(): void { - document.documentElement.style.setProperty("overflow", this.initialOverflowCSS); + document.documentElement.style.setProperty("overflow", initialDocumentOverflowStyle); } private handleMutationObserver = (): void => {