diff --git a/packages/calcite-components/src/components/dialog/dialog.e2e.ts b/packages/calcite-components/src/components/dialog/dialog.e2e.ts index f4e02be30b8..483cc123221 100644 --- a/packages/calcite-components/src/components/dialog/dialog.e2e.ts +++ b/packages/calcite-components/src/components/dialog/dialog.e2e.ts @@ -53,12 +53,7 @@ describe("calcite-dialog", () => { describe("openClose", () => { openClose("calcite-dialog"); - - describe("initially open", () => { - openClose("calcite-dialog", { - initialToggleValue: true, - }); - }); + openClose.initial("calcite-dialog"); }); describe("slots", () => { diff --git a/packages/calcite-components/src/components/input-time-picker/input-time-picker.e2e.ts b/packages/calcite-components/src/components/input-time-picker/input-time-picker.e2e.ts index e435103cd53..6232e958b6c 100644 --- a/packages/calcite-components/src/components/input-time-picker/input-time-picker.e2e.ts +++ b/packages/calcite-components/src/components/input-time-picker/input-time-picker.e2e.ts @@ -101,10 +101,7 @@ describe("calcite-input-time-picker", () => { describe("openClose", () => { openClose("calcite-input-time-picker"); - - describe.skip("initially open", () => { - openClose("calcite-input-time-picker", { initialToggleValue: true }); - }); + openClose.initial("calcite-input-time-picker"); }); it("when set to readOnly, element still focusable but won't display the controls or allow for changing the value", async () => { diff --git a/packages/calcite-components/src/components/input-time-zone/input-time-zone.e2e.ts b/packages/calcite-components/src/components/input-time-zone/input-time-zone.e2e.ts index da4b93d456e..1571d349924 100644 --- a/packages/calcite-components/src/components/input-time-zone/input-time-zone.e2e.ts +++ b/packages/calcite-components/src/components/input-time-zone/input-time-zone.e2e.ts @@ -8,6 +8,7 @@ import { formAssociated, hidden, labelable, + openClose, reflects, renders, t9n, @@ -128,6 +129,27 @@ describe("calcite-input-time-zone", () => { t9n(simpleTestProvider); }); + describe("openClose", () => { + openClose(simpleTestProvider); + + describe("initially open", () => { + openClose.initial("calcite-input-time-zone", { + beforeContent: async (page) => { + await page.emulateTimezone(testTimeZoneItems[0].name); + + // we add the override script this way because `setContent` was already used before this hook, and calling it again will result in an error. + await page.evaluate( + (supportedTimeZoneOverrideHtml) => + document.body.insertAdjacentHTML("beforeend", supportedTimeZoneOverrideHtml), + overrideSupportedTimeZones(""), + ); + + await page.waitForChanges(); + }, + }); + }); + }); + describe("mode", () => { describe("offset (default)", () => { describe("selects user's matching time zone offset on initialization", () => { @@ -154,7 +176,7 @@ describe("calcite-input-time-zone", () => { const page = await newE2EPage(); await page.emulateTimezone(testTimeZoneItems[0].name); await page.setContent( - await overrideSupportedTimeZones( + overrideSupportedTimeZones( html``, ), ); @@ -172,7 +194,7 @@ describe("calcite-input-time-zone", () => { const page = await newE2EPage(); await page.emulateTimezone(testTimeZoneItems[0].name); await page.setContent( - await overrideSupportedTimeZones(html``), + overrideSupportedTimeZones(html``), ); const input = await page.find("calcite-input-time-zone"); diff --git a/packages/calcite-components/src/components/input-time-zone/input-time-zone.tsx b/packages/calcite-components/src/components/input-time-zone/input-time-zone.tsx index 6e0dd6459fa..d27f07ef92f 100644 --- a/packages/calcite-components/src/components/input-time-zone/input-time-zone.tsx +++ b/packages/calcite-components/src/components/input-time-zone/input-time-zone.tsx @@ -191,6 +191,12 @@ export class InputTimeZone /** When `true`, displays and positions the component. */ @Prop({ mutable: true, reflect: true }) open = false; + @Watch("open") + openChanged(): void { + // we set the property instead of the attribute to ensure open/close events are emitted properly + this.comboboxEl.open = this.open; + } + /** * Determines the type of positioning to use for the overlaid content. * @@ -487,6 +493,7 @@ export class InputTimeZone componentDidLoad(): void { setComponentLoaded(this); this.overrideSelectedLabelForRegion(this.open); + this.openChanged(); } componentDidRender(): void { @@ -508,7 +515,6 @@ export class InputTimeZone onCalciteComboboxChange={this.onComboboxChange} onCalciteComboboxClose={this.onComboboxClose} onCalciteComboboxOpen={this.onComboboxOpen} - open={this.open} overlayPositioning={this.overlayPositioning} placeholder={ this.mode === "name" diff --git a/packages/calcite-components/src/components/modal/modal.e2e.ts b/packages/calcite-components/src/components/modal/modal.e2e.ts index d1914311e15..dbb4c945c25 100644 --- a/packages/calcite-components/src/components/modal/modal.e2e.ts +++ b/packages/calcite-components/src/components/modal/modal.e2e.ts @@ -15,12 +15,7 @@ describe("calcite-modal", () => { describe("openClose", () => { openClose("calcite-modal"); - - describe("initially open", () => { - openClose("calcite-modal", { - initialToggleValue: true, - }); - }); + openClose.initial("calcite-modal"); }); describe("slots", () => { diff --git a/packages/calcite-components/src/components/sheet/sheet.e2e.ts b/packages/calcite-components/src/components/sheet/sheet.e2e.ts index 94b50a9d8df..2c9408b547f 100644 --- a/packages/calcite-components/src/components/sheet/sheet.e2e.ts +++ b/packages/calcite-components/src/components/sheet/sheet.e2e.ts @@ -81,12 +81,7 @@ describe("calcite-sheet properties", () => { describe("openClose", () => { openClose("calcite-sheet"); - - describe("initially open", () => { - openClose("calcite-sheet", { - initialToggleValue: true, - }); - }); + openClose.initial("calcite-sheet"); }); it("sets custom width correctly", async () => { diff --git a/packages/calcite-components/src/tests/commonTests/interfaces.ts b/packages/calcite-components/src/tests/commonTests/interfaces.ts index 05ae276147e..a5665015bf8 100644 --- a/packages/calcite-components/src/tests/commonTests/interfaces.ts +++ b/packages/calcite-components/src/tests/commonTests/interfaces.ts @@ -11,9 +11,9 @@ export type TagAndPage = { page: E2EPage; }; -export type TagOrHTMLWithBeforeContent = { - tagOrHTML: TagOrHTML; +export type TagOrHTMLWithBeforeContent = WithBeforeContent<{ tagOrHTML: TagOrHTML }>; +export type WithBeforeContent = TestContent & { /** * Allows for custom setup of the page. * diff --git a/packages/calcite-components/src/tests/commonTests/openClose.ts b/packages/calcite-components/src/tests/commonTests/openClose.ts index fa5386ae8ea..71ceded7fad 100644 --- a/packages/calcite-components/src/tests/commonTests/openClose.ts +++ b/packages/calcite-components/src/tests/commonTests/openClose.ts @@ -1,8 +1,8 @@ import { E2EPage } from "@stencil/core/testing"; import { toHaveNoViolations } from "jest-axe"; import { GlobalTestProps, newProgrammaticE2EPage } from "../utils"; -import { getTag, simplePageSetup } from "./utils"; -import { TagOrHTML } from "./interfaces"; +import { getBeforeContent, getTagAndPage, noopBeforeContent } from "./utils"; +import { ComponentTag, ComponentTestSetup, WithBeforeContent } from "./interfaces"; expect.extend(toHaveNoViolations); @@ -24,11 +24,6 @@ interface OpenCloseOptions { */ openPropName?: string; - /** - * Indicates the initial value of the toggle property. - */ - initialToggleValue?: boolean; - /** * Optional argument with functions to simulate user input (mouse or keyboard), to open or close the component. */ @@ -40,6 +35,11 @@ interface OpenCloseOptions { willUseFallback?: boolean; } +const defaultOptions: OpenCloseOptions = { + openPropName: "open", + willUseFallback: false, +}; + /** * Helper to test openClose component setup. * @@ -48,201 +48,256 @@ interface OpenCloseOptions { * @example * * describe("openClose", () => { - * openClose("calcite-combobox", { - * beforeToggle: { - * open: async (page) => { - * await page.keyboard.press("Tab"); - * await page.waitForChanges(); - * }, - * close: async (page) => { - * await page.keyboard.press("Tab"); - * await page.waitForChanges(); - * }, - * } - * }); - * - * describe("initially open", () => { - * openClose("calcite-combobox", { - * initialToggleValue: true, - * beforeToggle: { - * close: async (page) => { - * await page.keyboard.press("Tab"); - * await page.waitForChanges(); - * }, - * } - * } + * openClose("calcite-combobox"); + * openClose.initial("calcite-combobox", { + * beforeContent: async (page: E2EPage) => { + * // configure page before component is created and appended + * } * }); * }); * - * @param componentTagOrHTML - The component tag or HTML markup to test against. + * + * @param {ComponentTestSetup} componentTestSetup - A component tag, html, or the tag and e2e page for setting up a test. * @param {object} [options] - Additional options to assert. */ +export function openClose(componentTestSetup: ComponentTestSetup, options?: OpenCloseOptions): void { + const effectiveOptions = { ...defaultOptions, ...options }; + + it(`emits with animations enabled`, async () => { + const { page, tag } = await getTagAndPage(componentTestSetup); + await page.addStyleTag({ + content: `:root { --calcite-duration-factor: 2; }`, + }); -export function openClose(componentTagOrHTML: TagOrHTML, options?: OpenCloseOptions): void { - const defaultOptions: OpenCloseOptions = { - initialToggleValue: false, - openPropName: "open", - willUseFallback: false, + await setUpEventListeners(tag, page); + await testOpenCloseEvents({ + tag, + page, + openPropName: effectiveOptions.openPropName, + beforeToggle: effectiveOptions.beforeToggle, + animationsEnabled: !effectiveOptions.willUseFallback, + }); + }); + + it(`emits with animations disabled`, async () => { + const { page, tag } = await getTagAndPage(componentTestSetup); + await page.addStyleTag({ + content: `:root { --calcite-duration-factor: 0; }`, + }); + await setUpEventListeners(tag, page); + await testOpenCloseEvents({ + animationsEnabled: false, + beforeToggle: effectiveOptions.beforeToggle, + openPropName: effectiveOptions.openPropName, + page, + tag, + }); + }); +} + +/** + * Helper to test openClose component setup on initialization. + * + * @param componentTag - The component tag to test. + * @param options - Additional options to assert. + */ +openClose.initial = function openCloseInitial( + componentTag: ComponentTag, + options?: WithBeforeContent, +): void { + const effectiveOptions = { + ...defaultOptions, + beforeContent: noopBeforeContent, + ...options, }; - const customizedOptions = { ...defaultOptions, ...options }; - type EventOrderWindow = GlobalTestProps<{ events: string[] }>; - const eventSequence = setUpEventSequence(componentTagOrHTML); + const tag = componentTag; + const beforeContent = getBeforeContent(effectiveOptions); - function setUpEventSequence(componentTagOrHTML: TagOrHTML): string[] { - const tag = getTag(componentTagOrHTML); + it("emits on initialization with animations enabled", async () => { + const page = await newProgrammaticE2EPage(); + await page.addStyleTag({ + content: `:root { --calcite-duration-factor: 2; }`, + }); + await beforeContent(page); + await setUpEventListeners(tag, page); + await testOpenCloseEvents({ + animationsEnabled: true, + beforeToggle: effectiveOptions.beforeToggle, + openPropName: effectiveOptions.openPropName, + page, + startOpen: true, + tag, + }); + }); - const camelCaseTag = tag.replace(/-([a-z])/g, (lettersAfterHyphen) => lettersAfterHyphen[1].toUpperCase()); - const eventSuffixes = [`BeforeOpen`, `Open`, `BeforeClose`, `Close`]; + it("emits on initialization with animations disabled", async () => { + const page = await newProgrammaticE2EPage(); + await page.addStyleTag({ + content: `:root { --calcite-duration-factor: 0; }`, + }); + await beforeContent(page); + await setUpEventListeners(tag, page); + await testOpenCloseEvents({ + animationsEnabled: false, + beforeToggle: effectiveOptions.beforeToggle, + openPropName: effectiveOptions.openPropName, + page, + startOpen: true, + tag, + }); + }); +}; - return eventSuffixes.map((suffix) => `${camelCaseTag}${suffix}`); - } +interface TestOpenCloseEventsParams { + /** + * The component tag to test. + */ + tag: ComponentTag; - async function setUpPage(componentTagOrHTML: TagOrHTML, page: E2EPage): Promise { - await page.evaluate( - (eventSequence: string[], initialToggleValue: boolean, openPropName: string, componentTagOrHTML: string) => { - const receivedEvents: string[] = []; + /** + * The E2E page instance. + */ + page: E2EPage; - (window as EventOrderWindow).events = receivedEvents; + /** + * The property name used to control the open state of the component. + */ + openPropName: string; - eventSequence.forEach((eventType) => { - document.addEventListener(eventType, (event) => receivedEvents.push(event.type)); - }); + /** + * Whether the component should start in the open state. + */ + startOpen?: boolean; - if (!initialToggleValue) { - return; - } + /** + * Functions to simulate user input (mouse or keyboard) to open or close the component. + */ + beforeToggle?: BeforeToggle; + + /** + * Whether animations are enabled. + */ + animationsEnabled: boolean; +} + +async function testOpenCloseEvents({ + animationsEnabled, + beforeToggle, + openPropName, + page, + startOpen = false, + tag, +}: TestOpenCloseEventsParams): Promise { + const timestamps: Record = { + beforeOpen: undefined, + open: undefined, + beforeClose: undefined, + close: undefined, + }; + const eventSequence = getEventSequence(tag); + + const [beforeOpenEvent, openEvent, beforeCloseEvent, closeEvent] = eventSequence.map((event) => { + return page.waitForEvent(event).then((spy) => { + timestamps[toOpenCloseName(event)] = Date.now(); + return spy; + }); + }); + const [beforeOpenSpy, openSpy, beforeCloseSpy, closeSpy] = await Promise.all( + eventSequence.map(async (event) => await page.spyOnEvent(event)), + ); + + function assertEventSequence(expectedTimesPerEvent: [number, number, number, number]): void { + expect(beforeOpenSpy).toHaveReceivedEventTimes(expectedTimesPerEvent[0]); + expect(openSpy).toHaveReceivedEventTimes(expectedTimesPerEvent[1]); + expect(beforeCloseSpy).toHaveReceivedEventTimes(expectedTimesPerEvent[2]); + expect(closeSpy).toHaveReceivedEventTimes(expectedTimesPerEvent[3]); + } + + if (startOpen) { + await page.evaluate( + (openPropName: string, componentTagOrHTML: string) => { const component = document.createElement(componentTagOrHTML); component[openPropName] = true; document.body.append(component); }, - eventSequence, - customizedOptions.initialToggleValue, - customizedOptions.openPropName, - componentTagOrHTML, + openPropName, + tag, ); } - type OpenCloseName = "beforeOpen" | "open" | "beforeClose" | "close"; + const element = await page.find(tag); + await page.waitForChanges(); - function toOpenCloseName(eventName: string): OpenCloseName { - return eventName.includes("BeforeOpen") - ? "beforeOpen" - : eventName.includes("Open") - ? "open" - : eventName.includes("BeforeClose") - ? "beforeClose" - : "close"; + if (!startOpen) { + if (beforeToggle) { + await beforeToggle.open(page); + } else { + element.setProperty(openPropName, true); + } } - async function testOpenCloseEvents( - componentTagOrHTML: TagOrHTML, - page: E2EPage, - animationsEnabled = true, - ): Promise { - const tag = getTag(componentTagOrHTML); - const element = await page.find(tag); - - const timestamps: Record = { - beforeOpen: undefined, - open: undefined, - beforeClose: undefined, - close: undefined, - }; - - const [beforeOpenEvent, openEvent, beforeCloseEvent, closeEvent] = eventSequence.map((event) => { - return page.waitForEvent(event).then((spy) => { - timestamps[toOpenCloseName(event)] = Date.now(); - return spy; - }); - }); + await page.waitForChanges(); + await beforeOpenEvent; + await openEvent; - const [beforeOpenSpy, openSpy, beforeCloseSpy, closeSpy] = await Promise.all( - eventSequence.map(async (event) => await element.spyOnEvent(event)), - ); + assertEventSequence([1, 1, 0, 0]); - function assertEventSequence(expectedTimesPerEvent: [number, number, number, number]): void { - expect(beforeOpenSpy).toHaveReceivedEventTimes(expectedTimesPerEvent[0]); - expect(openSpy).toHaveReceivedEventTimes(expectedTimesPerEvent[1]); - expect(beforeCloseSpy).toHaveReceivedEventTimes(expectedTimesPerEvent[2]); - expect(closeSpy).toHaveReceivedEventTimes(expectedTimesPerEvent[3]); - } + if (startOpen || !beforeToggle) { + element.setProperty(openPropName, false); + } else { + await beforeToggle.close(page); + } - await page.waitForChanges(); + await page.waitForChanges(); + await beforeCloseEvent; + await closeEvent; - if (customizedOptions.beforeToggle) { - await customizedOptions.beforeToggle.open(page); - } else { - element.setProperty(customizedOptions.openPropName, true); - } + assertEventSequence([1, 1, 1, 1]); - await page.waitForChanges(); - await beforeOpenEvent; - await openEvent; + expect(await page.evaluate(() => (window as EventOrderWindow).events)).toEqual(eventSequence); - assertEventSequence([1, 1, 0, 0]); + const delayDeltaThreshold = 100; // smallest internal animation timing used + const delayBetweenBeforeOpenAndOpen = timestamps.open - timestamps.beforeOpen; + const delayBetweenBeforeCloseAndClose = timestamps.close - timestamps.beforeClose; - if (customizedOptions.beforeToggle) { - await customizedOptions.beforeToggle.close(page); - } else { - element.setProperty(customizedOptions.openPropName, false); - } + const matcherName = animationsEnabled ? "toBeGreaterThan" : "toBeLessThanOrEqual"; - await page.waitForChanges(); - await beforeCloseEvent; - await closeEvent; + expect(delayBetweenBeforeOpenAndOpen)[matcherName](delayDeltaThreshold); + expect(delayBetweenBeforeCloseAndClose)[matcherName](delayDeltaThreshold); +} - assertEventSequence([1, 1, 1, 1]); +type EventOrderWindow = GlobalTestProps<{ events: string[] }>; - expect(await page.evaluate(() => (window as EventOrderWindow).events)).toEqual(eventSequence); +function getEventSequence(componentTag: ComponentTag): string[] { + const camelCaseTag = componentTag.replace(/-([a-z])/g, (lettersAfterHyphen) => lettersAfterHyphen[1].toUpperCase()); + const eventSuffixes = [`BeforeOpen`, `Open`, `BeforeClose`, `Close`]; - const delayDeltaThreshold = 100; // smallest internal animation timing used - const delayBetweenBeforeOpenAndOpen = timestamps.open - timestamps.beforeOpen; - const delayBetweenBeforeCloseAndClose = timestamps.close - timestamps.beforeClose; + return eventSuffixes.map((suffix) => `${camelCaseTag}${suffix}`); +} - const matcherName = animationsEnabled ? "toBeGreaterThan" : ("toBeLessThanOrEqual" as const); +async function setUpEventListeners(componentTag: ComponentTag, page: E2EPage): Promise { + await page.evaluate((eventSequence: string[]) => { + const receivedEvents: string[] = []; - expect(delayBetweenBeforeOpenAndOpen)[matcherName](delayDeltaThreshold); - expect(delayBetweenBeforeCloseAndClose)[matcherName](delayDeltaThreshold); - } + (window as EventOrderWindow).events = receivedEvents; - if (customizedOptions.initialToggleValue === true) { - it("emits on initialization with animations enabled", async () => { - const page = await newProgrammaticE2EPage(); - await page.addStyleTag({ - content: `:root { --calcite-duration-factor: 2; }`, - }); - await setUpPage(componentTagOrHTML, page); - await testOpenCloseEvents(componentTagOrHTML, page, !customizedOptions.willUseFallback); + eventSequence.forEach((eventType) => { + document.addEventListener(eventType, (event) => receivedEvents.push(event.type)); }); + }, getEventSequence(componentTag)); +} - it("emits on initialization with animations disabled", async () => { - const page = await newProgrammaticE2EPage(); - await page.addStyleTag({ - content: `:root { --calcite-duration-factor: 0; }`, - }); - await setUpPage(componentTagOrHTML, page); - await testOpenCloseEvents(componentTagOrHTML, page, false); - }); - } else { - it(`emits with animations enabled`, async () => { - const page = await simplePageSetup(componentTagOrHTML); - await page.addStyleTag({ - content: `:root { --calcite-duration-factor: 2; }`, - }); - await setUpPage(componentTagOrHTML, page); - await testOpenCloseEvents(componentTagOrHTML, page, !customizedOptions.willUseFallback); - }); +type OpenCloseName = "beforeOpen" | "open" | "beforeClose" | "close"; - it(`emits with animations disabled`, async () => { - const page = await simplePageSetup(componentTagOrHTML); - await page.addStyleTag({ - content: `:root { --calcite-duration-factor: 0; }`, - }); - await setUpPage(componentTagOrHTML, page); - await testOpenCloseEvents(componentTagOrHTML, page, false); - }); - } +function toOpenCloseName(eventName: string): OpenCloseName { + return eventName.includes("BeforeOpen") + ? "beforeOpen" + : eventName.includes("Open") + ? "open" + : eventName.includes("BeforeClose") + ? "beforeClose" + : "close"; } diff --git a/packages/calcite-components/src/tests/commonTests/utils.ts b/packages/calcite-components/src/tests/commonTests/utils.ts index 679418eac61..bc75161907f 100644 --- a/packages/calcite-components/src/tests/commonTests/utils.ts +++ b/packages/calcite-components/src/tests/commonTests/utils.ts @@ -8,6 +8,8 @@ import type { TagAndPage, TagOrHTMLWithBeforeContent, BeforeContent, + WithBeforeContent, + ComponentTestContent, } from "./interfaces"; expect.extend(toHaveNoViolations); @@ -57,6 +59,18 @@ export async function getTagAndPage(componentTestSetup: ComponentTestSetup): Pro return componentTestSetup; } +export async function noopBeforeContent(): Promise { + /* noop */ +} + +export function getBeforeContent( + componentTestSetup: WithBeforeContent, +): BeforeContent { + return typeof componentTestSetup === "string" + ? noopBeforeContent + : componentTestSetup?.beforeContent || noopBeforeContent; +} + export function getTagOrHTMLWithBeforeContent(componentTestSetup: TagOrHTML | TagOrHTMLWithBeforeContent): { tagOrHTML: TagOrHTML; beforeContent?: BeforeContent;