diff --git a/packages/calcite-components/src/components/modal/modal.e2e.ts b/packages/calcite-components/src/components/modal/modal.e2e.ts index 68100fcbecb..830d4699ee9 100644 --- a/packages/calcite-components/src/components/modal/modal.e2e.ts +++ b/packages/calcite-components/src/components/modal/modal.e2e.ts @@ -23,7 +23,7 @@ describe("calcite-modal properties", () => { const modal = await page.find("calcite-modal"); modal.setProperty("closeButtonDisabled", true); await page.waitForChanges(); - const closeButton = await page.find("calcite-modal >>> .close"); + const closeButton = await page.find(`calcite-modal >>> .${CSS.close}`); expect(closeButton).toBe(null); }); @@ -298,6 +298,55 @@ describe("opening and closing behavior", () => { ]); }); + it("emits when closing on click", 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"); + + expect(beforeOpenSpy).toHaveReceivedEventTimes(0); + expect(openSpy).toHaveReceivedEventTimes(0); + expect(beforeCloseSpy).toHaveReceivedEventTimes(0); + expect(closeSpy).toHaveReceivedEventTimes(0); + + expect(await modal.isVisible()).toBe(false); + + const modalBeforeOpen = page.waitForEvent("calciteModalBeforeOpen"); + const modalOpen = page.waitForEvent("calciteModalOpen"); + modal.setProperty("open", true); + await page.waitForChanges(); + + await modalBeforeOpen; + await modalOpen; + + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); + expect(beforeCloseSpy).toHaveReceivedEventTimes(0); + expect(closeSpy).toHaveReceivedEventTimes(0); + + expect(await modal.isVisible()).toBe(true); + + 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(); + + await modalBeforeClose; + await modalClose; + + expect(beforeOpenSpy).toHaveReceivedEventTimes(1); + expect(openSpy).toHaveReceivedEventTimes(1); + expect(beforeCloseSpy).toHaveReceivedEventTimes(1); + expect(closeSpy).toHaveReceivedEventTimes(1); + + expect(await modal.isVisible()).toBe(false); + }); + it("emits when set to open on initial render", async () => { const page = await newProgrammaticE2EPage(); @@ -474,7 +523,7 @@ describe("calcite-modal accessibility checks", () => { const createModalHTML = (contentHTML?: string, attrs?: string) => `${contentHTML}`; - const closeButtonTargetSelector = ".close"; + const closeButtonTargetSelector = `.${CSS.close}`; const focusableContentTargetClass = "test"; const focusableContentHTML = html`

Title

@@ -543,7 +592,7 @@ describe("calcite-modal accessibility checks", () => { modal.setProperty("open", true); await page.waitForChanges(); expect(await modal.isVisible()).toBe(true); - const closeButton = await page.find("calcite-modal >>> .close"); + const closeButton = await page.find(`calcite-modal >>> .${CSS.close}`); await closeButton.click(); await page.waitForChanges(); expect(await modal.isVisible()).toBe(false); diff --git a/packages/calcite-components/src/components/modal/modal.tsx b/packages/calcite-components/src/components/modal/modal.tsx index db9dcffd38e..c661d852551 100644 --- a/packages/calcite-components/src/components/modal/modal.tsx +++ b/packages/calcite-components/src/components/modal/modal.tsx @@ -172,7 +172,6 @@ export class Modal setUpLoadableComponent(this); // when modal initially renders, if active was set we need to open as watcher doesn't fire if (this.open) { - onToggleOpenCloseComponent(this); requestAnimationFrame(() => this.openModal()); } } @@ -290,7 +289,7 @@ export class Modal aria-label={this.messages.close} class={CSS.close} key="button" - onClick={this.closeModal} + onClick={this.handleCloseClick} title={this.messages.close} // eslint-disable-next-line react/jsx-sort-props -- ref should be last so node attrs/props are in sync (see https://github.com/Esri/calcite-design-system/pull/6530) ref={(el) => (this.closeButtonEl = el)} @@ -405,7 +404,7 @@ export class Modal @Listen("keydown", { target: "window" }) handleEscape(event: KeyboardEvent): void { if (this.open && !this.escapeDisabled && event.key === "Escape" && !event.defaultPrevented) { - this.closeModal(); + this.open = false; event.preventDefault(); } } @@ -502,18 +501,25 @@ export class Modal } @Watch("open") - async toggleModal(value: boolean): Promise { + toggleModal(value: boolean): void { if (this.ignoreOpenChange) { return; } + if (value) { + this.openModal(); + } else { + this.closeModal(); + } + } + + @Watch("opened") + handleOpenedChange(value: boolean): void { onToggleOpenCloseComponent(this); if (value) { this.transitionEl?.classList.add(CSS.openingIdle); - this.openModal(); } else { this.transitionEl?.classList.add(CSS.closingIdle); - this.closeModal(); } } @@ -522,15 +528,12 @@ export class Modal this.el.removeEventListener("calciteModalOpen", this.openEnd); }; - /** Open the modal */ - private openModal() { - if (this.ignoreOpenChange) { - return; - } + private handleCloseClick = () => { + this.open = false; + }; - this.ignoreOpenChange = true; + private openModal() { this.el.addEventListener("calciteModalOpen", this.openEnd); - this.open = true; this.opened = true; const titleEl = getSlotted(this.el, SLOTS.header); const contentEl = getSlotted(this.el, SLOTS.content); @@ -543,7 +546,6 @@ export class Modal // use an inline style instead of a utility class to avoid global class declarations. document.documentElement.style.setProperty("overflow", "hidden"); } - this.ignoreOpenChange = false; } private handleOutsideClose = (): void => { @@ -551,15 +553,10 @@ export class Modal return; } - this.closeModal(); + this.open = false; }; - /** Close the modal, first running the `beforeClose` method */ closeModal = async (): Promise => { - if (this.ignoreOpenChange) { - return; - } - if (this.beforeClose) { try { await this.beforeClose(this.el); @@ -574,11 +571,8 @@ export class Modal } } - this.ignoreOpenChange = true; - this.open = false; this.opened = false; this.removeOverflowHiddenClass(); - this.ignoreOpenChange = false; }; private removeOverflowHiddenClass(): void { diff --git a/packages/calcite-components/src/demos/modal.html b/packages/calcite-components/src/demos/modal.html index 48f48a382d9..16fa4782b92 100644 --- a/packages/calcite-components/src/demos/modal.html +++ b/packages/calcite-components/src/demos/modal.html @@ -1092,6 +1092,22 @@

Test custom sizes

Custom width and height preview + + +
+ +

Test rejected beforeClose

+
test
+ Cancel +
+ + beforeClose rejected + +
@@ -1100,6 +1116,11 @@

Test custom sizes

const heightInput = document.querySelector("#css-modal-height-adjuster"); const widthInput = document.querySelector("#css-modal-width-adjuster"); const optionsSegmentedControl = document.querySelector("#css-modal-options-adjuster"); + const beforeCloseRejected = document.getElementById("js-modal-before-close"); + + beforeCloseRejected.beforeClose = () => { + return new Promise((_resolve, reject) => setTimeout(reject, 300)); + }; heightInput.addEventListener("calciteInputInput", (event) => { customSizeModal.style.setProperty("--calcite-modal-height", event.target.value); diff --git a/packages/calcite-components/src/utils/openCloseComponent.ts b/packages/calcite-components/src/utils/openCloseComponent.ts index cc10277457e..67d10f3a139 100644 --- a/packages/calcite-components/src/utils/openCloseComponent.ts +++ b/packages/calcite-components/src/utils/openCloseComponent.ts @@ -14,6 +14,11 @@ export interface OpenCloseComponent { */ open?: boolean; + /** + * When true, the component is open. + */ + opened?: boolean; + /** * Specifies the name of transitionProp. */ @@ -55,22 +60,26 @@ const componentToTransitionListeners = new WeakMap< [HTMLDivElement, typeof transitionStart, typeof transitionEnd] >(); -function transitionStart(event: TransitionEvent): void { +function transitionStart(this: OpenCloseComponent, event: TransitionEvent): void { if (event.propertyName === this.openTransitionProp && event.target === this.transitionEl) { - this.open ? this.onBeforeOpen() : this.onBeforeClose(); + isOpen(this) ? this.onBeforeOpen() : this.onBeforeClose(); } } -function transitionEnd(event: TransitionEvent): void { +function transitionEnd(this: OpenCloseComponent, event: TransitionEvent): void { if (event.propertyName === this.openTransitionProp && event.target === this.transitionEl) { - this.open ? this.onOpen() : this.onClose(); + isOpen(this) ? this.onOpen() : this.onClose(); } } +function isOpen(component: OpenCloseComponent): boolean { + return "opened" in component ? component.opened : component.open; +} + function emitImmediately(component: OpenCloseComponent, nonOpenCloseComponent = false): void { - (nonOpenCloseComponent ? component[component.transitionProp] : component.open) + (nonOpenCloseComponent ? component[component.transitionProp] : isOpen(component)) ? component.onBeforeOpen() : component.onBeforeClose(); - (nonOpenCloseComponent ? component[component.transitionProp] : component.open) + (nonOpenCloseComponent ? component[component.transitionProp] : isOpen(component)) ? component.onOpen() : component.onClose(); } @@ -131,7 +140,7 @@ export function onToggleOpenCloseComponent(component: OpenCloseComponent, nonOpe if (event.propertyName === component.openTransitionProp && event.target === component.transitionEl) { clearTimeout(fallbackTimeoutId); component.transitionEl.removeEventListener("transitionstart", onStart); - (nonOpenCloseComponent ? component[component.transitionProp] : component.open) + (nonOpenCloseComponent ? component[component.transitionProp] : isOpen(component)) ? component.onBeforeOpen() : component.onBeforeClose(); } @@ -139,7 +148,7 @@ export function onToggleOpenCloseComponent(component: OpenCloseComponent, nonOpe function onEndOrCancel(event: TransitionEvent): void { if (event.propertyName === component.openTransitionProp && event.target === component.transitionEl) { - (nonOpenCloseComponent ? component[component.transitionProp] : component.open) + (nonOpenCloseComponent ? component[component.transitionProp] : isOpen(component)) ? component.onOpen() : component.onClose();