diff --git a/packages/calcite-components/src/tests/spec-helpers/animationEvents.ts b/packages/calcite-components/src/tests/spec-helpers/animationEvents.ts new file mode 100644 index 00000000000..48cf8af43ed --- /dev/null +++ b/packages/calcite-components/src/tests/spec-helpers/animationEvents.ts @@ -0,0 +1,25 @@ +export interface AnimationEventDispatcher { + (element: HTMLElement, type: "animationstart" | "animationend", animationName: string): void; +} + +/** + * Must be called in a `beforeEach` block to create a animation event dispatcher. + */ +export function createAnimationEventDispatcher(): AnimationEventDispatcher { + // we define AnimationEvent since JSDOM doesn't support it yet - + class AnimationEvent extends window.Event { + elapsedTime: number; + + animationName: string; + + constructor(type: string, eventInitDict: EventInit & Partial<{ elapsedTime: number; animationName: string }>) { + super(type, eventInitDict); + this.elapsedTime = eventInitDict.elapsedTime; + this.animationName = eventInitDict.animationName; + } + } + + return (element: HTMLElement, type: "animationstart" | "animationend", animationName: string): void => { + element.dispatchEvent(new AnimationEvent(type, { animationName })); + }; +} diff --git a/packages/calcite-components/src/tests/spec-helpers/computedStyle.ts b/packages/calcite-components/src/tests/spec-helpers/computedStyle.ts new file mode 100644 index 00000000000..617fa417fb1 --- /dev/null +++ b/packages/calcite-components/src/tests/spec-helpers/computedStyle.ts @@ -0,0 +1,14 @@ +/** + * Mocks `getComputedStyle` to return the provided values for the provided element. + * This is needed due to JSDOM issue with getComputedStyle - https://github.com/jsdom/jsdom/issues/3090 + * + * @param element + * @param fakeComputedStyle + */ +export function mockGetComputedStyleFor(element: Element, fakeComputedStyle: Partial): void { + jest.spyOn(window, "getComputedStyle").mockImplementation((el: Element): CSSStyleDeclaration => { + if (el === element) { + return fakeComputedStyle as CSSStyleDeclaration; + } + }); +} diff --git a/packages/calcite-components/src/tests/spec-helpers/transitionEvents.ts b/packages/calcite-components/src/tests/spec-helpers/transitionEvents.ts new file mode 100644 index 00000000000..0d36495932b --- /dev/null +++ b/packages/calcite-components/src/tests/spec-helpers/transitionEvents.ts @@ -0,0 +1,25 @@ +export interface TransitionEventDispatcher { + (element: HTMLElement, type: "transitionstart" | "transitionend", propertyName: string): void; +} + +/** + * Must be called in a `beforeEach` block to create a transition event dispatcher. + */ +export function createTransitionEventDispatcher(): TransitionEventDispatcher { + // we define TransitionEvent since JSDOM doesn't support it yet - https://github.com/jsdom/jsdom/issues/1781 + class TransitionEvent extends window.Event { + elapsedTime: number; + + propertyName: string; + + constructor(type: string, eventInitDict: EventInit & Partial<{ elapsedTime: number; propertyName: string }>) { + super(type, eventInitDict); + this.elapsedTime = eventInitDict.elapsedTime; + this.propertyName = eventInitDict.propertyName; + } + } + + return (element: HTMLElement, type: "transitionstart" | "transitionend", propertyName: string): void => { + element.dispatchEvent(new TransitionEvent(type, { propertyName })); + }; +} diff --git a/packages/calcite-components/src/utils/dom.spec.ts b/packages/calcite-components/src/utils/dom.spec.ts index 5eaf3d29592..41d71a79775 100644 --- a/packages/calcite-components/src/utils/dom.spec.ts +++ b/packages/calcite-components/src/utils/dom.spec.ts @@ -1,6 +1,9 @@ -import { JSDOM } from "jsdom"; -import { ModeName } from "../../src/components/interfaces"; +import { ModeName } from "../components/interfaces"; import { html } from "../../support/formatting"; +import { createTransitionEventDispatcher, TransitionEventDispatcher } from "../tests/spec-helpers/transitionEvents"; +import { AnimationEventDispatcher, createAnimationEventDispatcher } from "../tests/spec-helpers/animationEvents"; +import { mockGetComputedStyleFor } from "../tests/spec-helpers/computedStyle"; +import { guidPattern } from "./guid.spec"; import { ensureId, focusElementInGroup, @@ -23,7 +26,6 @@ import { whenAnimationDone, whenTransitionDone, } from "./dom"; -import { guidPattern } from "./guid.spec"; describe("dom", () => { describe("getElementProp()", () => { @@ -623,127 +625,196 @@ describe("dom", () => { } describe("whenTransitionDone", () => { - let dispatchTransitionEvent: ( - element: HTMLElement, - type: "transitionstart" | "transitionend", - propertyName: string, - ) => void; + const testProp = "opacity"; + const testDuration = "0.5s"; + + let element: HTMLDivElement; + let dispatchTransitionEvent: TransitionEventDispatcher; + let onStartCallback: jest.Mock; + let onEndCallback: jest.Mock; beforeEach(() => { - // we clobber Stencil's custom Mock document implementation - const { window: win } = new JSDOM(); + dispatchTransitionEvent = createTransitionEventDispatcher(); + element = window.document.createElement("div"); + onStartCallback = jest.fn(); + onEndCallback = jest.fn(); + }); + + it("should return a promise that resolves after the transition", async () => { + const testTransition = `${testProp} ${testDuration} ease 0s`; + + element.style.transition = testTransition; + window.document.body.append(element); + mockGetComputedStyleFor(element, { + transition: testTransition, + transitionDuration: testDuration, + transitionProperty: testProp, + }); - // eslint-disable-next-line no-global-assign -- overriding to make window references use JSDOM (which is a subset, hence the type cast) - window = win as any as Window & typeof globalThis; + const promise = whenTransitionDone(element, testProp, onStartCallback, onEndCallback); + element.style.opacity = "0"; - // we define TransitionEvent since JSDOM doesn't support it yet - https://github.com/jsdom/jsdom/issues/1781 - class TransitionEvent extends window.Event { - elapsedTime: number; + expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(onStartCallback).not.toHaveBeenCalled(); + expect(onEndCallback).not.toHaveBeenCalled(); - propertyName: string; + dispatchTransitionEvent(element, "transitionstart", testProp); - constructor(type: string, eventInitDict: EventInit & Partial<{ elapsedTime: number; propertyName: string }>) { - super(type, eventInitDict); - this.elapsedTime = eventInitDict.elapsedTime; - this.propertyName = eventInitDict.propertyName; - } - } + expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(onStartCallback).toHaveBeenCalled(); + expect(onEndCallback).not.toHaveBeenCalled(); + + dispatchTransitionEvent(element, "transitionend", testProp); - dispatchTransitionEvent = ( - element: HTMLElement, - type: "transitionstart" | "transitionend", - propertyName: string, - ): void => { - element.dispatchEvent(new TransitionEvent(type, { propertyName })); - }; + expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(onStartCallback).toHaveBeenCalled(); + expect(onEndCallback).toHaveBeenCalled(); + + expect(await promiseState(promise)).toHaveProperty("status", "fulfilled"); + expect(onStartCallback).toHaveBeenCalled(); + expect(onEndCallback).toHaveBeenCalled(); }); - it("should return a promise that resolves after the transition", async () => { - const element = window.document.createElement("div"); - const testProp = "opacity"; - const testDuration = "0.5s"; + it("should return a promise that resolves after 0s transition", async () => { + const testDuration = "0s"; // shadows the outer testDuration const testTransition = `${testProp} ${testDuration} ease 0s`; element.style.transition = testTransition; - - // need to mock due to JSDOM issue with getComputedStyle - https://github.com/jsdom/jsdom/issues/3090 - window.getComputedStyle = jest.fn().mockReturnValue({ + window.document.body.append(element); + mockGetComputedStyleFor(element, { transition: testTransition, transitionDuration: testDuration, transitionProperty: testProp, }); - window.document.body.append(element); - const promise = whenTransitionDone(element, "opacity"); + const promise = whenTransitionDone(element, testProp, onStartCallback, onEndCallback); element.style.opacity = "0"; expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(onStartCallback).toHaveBeenCalled(); + expect(onEndCallback).toHaveBeenCalled(); - dispatchTransitionEvent(element, "transitionstart", "opacity"); - expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(await promiseState(promise)).toHaveProperty("status", "fulfilled"); + }); + + it("should return a promise that resolves when called and transition has not started when expected", async () => { + const testTransition = `${testProp} ${testDuration} ease 0s`; - dispatchTransitionEvent(element, "transitionend", "opacity"); + element.style.transition = testTransition; + window.document.body.append(element); + mockGetComputedStyleFor(element, { + transition: testTransition, + transitionDuration: testDuration, + transitionProperty: testProp, + }); + + const promise = whenTransitionDone(element, testProp, onStartCallback, onEndCallback); + element.style.opacity = "0"; expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(onStartCallback).not.toHaveBeenCalled(); + expect(onEndCallback).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 500)); expect(await promiseState(promise)).toHaveProperty("status", "fulfilled"); + expect(onStartCallback).toHaveBeenCalled(); + expect(onEndCallback).toHaveBeenCalled(); }); }); describe("whenAnimationDone", () => { - let dispatchAnimationEvent: ( - element: HTMLElement, - type: "animationstart" | "animationend", - animationName: string, - ) => void; + const testAnimationName = "fade"; + const testDuration = "0.5s"; + + let element: HTMLDivElement; + let dispatchAnimationEvent: AnimationEventDispatcher; + let onStartCallback: jest.Mock; + let onEndCallback: jest.Mock; beforeEach(() => { - // we clobber Stencil's custom Mock document implementation - const { window: win } = new JSDOM(); + dispatchAnimationEvent = createAnimationEventDispatcher(); + element = window.document.createElement("div"); + onStartCallback = jest.fn(); + onEndCallback = jest.fn(); + }); + + it("should return a promise that resolves after the animation", async () => { + const testAnimation = `${testAnimationName} ${testDuration} ease 0s`; - // eslint-disable-next-line no-global-assign -- overriding to make window references use JSDOM (which is a subset, hence the type cast) - window = win as any as Window & typeof globalThis; + element.style.animation = testAnimation; + window.document.body.append(element); + mockGetComputedStyleFor(element, { + animation: testAnimation, + animationDuration: testDuration, + animationName: testAnimationName, + }); - // we define AnimationEvent since JSDOM doesn't support it yet - + const promise = whenAnimationDone(element, testAnimationName, onStartCallback, onEndCallback); + element.style.animationName = "none"; - class AnimationEvent extends window.Event { - elapsedTime: number; + expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(onStartCallback).not.toHaveBeenCalled(); + expect(onEndCallback).not.toHaveBeenCalled(); - animationName: string; + dispatchAnimationEvent(element, "animationstart", testAnimationName); - constructor(type: string, eventInitDict: EventInit & Partial<{ elapsedTime: number; animationName: string }>) { - super(type, eventInitDict); - this.elapsedTime = eventInitDict.elapsedTime; - this.animationName = eventInitDict.animationName; - } - } + expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(onStartCallback).toHaveBeenCalled(); + expect(onEndCallback).not.toHaveBeenCalled(); - dispatchAnimationEvent = ( - element: HTMLElement, - type: "animationstart" | "animationend", - animationName: string, - ): void => { - element.dispatchEvent(new AnimationEvent(type, { animationName })); - }; + dispatchAnimationEvent(element, "animationend", testAnimationName); + + expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(onStartCallback).toHaveBeenCalled(); + expect(onEndCallback).toHaveBeenCalled(); + + expect(await promiseState(promise)).toHaveProperty("status", "fulfilled"); + expect(onStartCallback).toHaveBeenCalled(); + expect(onEndCallback).toHaveBeenCalled(); }); - it("should return a promise that resolves after the animation", async () => { - const element = window.document.createElement("div"); - const testAnimationName = "fade"; - const testDuration = "0.5s"; + it("should return a promise that resolves after 0s animation", async () => { + const testDuration = "0s"; // shadows the outer testDuration + const testAnimation = `${testAnimationName} ${testDuration} ease 0s`; - element.style.animation = `${testAnimationName} ${testDuration} ease 0s`; + element.style.animation = testAnimation; window.document.body.append(element); + mockGetComputedStyleFor(element, { + animation: testAnimation, + animationDuration: testDuration, + animationName: testAnimationName, + }); - const promise = whenAnimationDone(element, testAnimationName); + const promise = whenAnimationDone(element, testAnimationName, onStartCallback, onEndCallback); element.style.animationName = "none"; expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(onStartCallback).toHaveBeenCalled(); + expect(onEndCallback).toHaveBeenCalled(); - dispatchAnimationEvent(element, "animationstart", testAnimationName); - expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(await promiseState(promise)).toHaveProperty("status", "fulfilled"); + }); - dispatchAnimationEvent(element, "animationend", testAnimationName); + it("should return a promise that resolves when called and animation has not started when expected", async () => { + const testAnimation = `${testAnimationName} ${testDuration} ease 0s`; + + element.style.animation = testAnimation; + window.document.body.append(element); + mockGetComputedStyleFor(element, { + animation: testAnimation, + animationDuration: testDuration, + animationName: testAnimationName, + }); + + const promise = whenAnimationDone(element, testAnimationName, onStartCallback, onEndCallback); + element.style.animationName = "none"; expect(await promiseState(promise)).toHaveProperty("status", "pending"); + expect(onStartCallback).not.toHaveBeenCalled(); + expect(onEndCallback).not.toHaveBeenCalled(); + + await new Promise((resolve) => setTimeout(resolve, 500)); expect(await promiseState(promise)).toHaveProperty("status", "fulfilled"); + expect(onStartCallback).toHaveBeenCalled(); + expect(onEndCallback).toHaveBeenCalled(); }); }); }); diff --git a/packages/calcite-components/src/utils/dom.ts b/packages/calcite-components/src/utils/dom.ts index bc37bc175b3..f4d363f0648 100644 --- a/packages/calcite-components/src/utils/dom.ts +++ b/packages/calcite-components/src/utils/dom.ts @@ -659,9 +659,16 @@ export function isBefore(a: HTMLElement, b: HTMLElement): boolean { * * @param targetEl The element to watch for the animation to complete. * @param animationName The name of the animation to watch for completion. + * @param onStart A callback to run when the animation starts. + * @param onEnd A callback to run when the animation ends or is canceled. */ -export async function whenAnimationDone(targetEl: HTMLElement, animationName: string): Promise { - return whenTransitionOrAnimationDone(targetEl, animationName, "animation"); +export async function whenAnimationDone( + targetEl: HTMLElement, + animationName: string, + onStart?: () => void, + onEnd?: () => void, +): Promise { + return whenTransitionOrAnimationDone(targetEl, animationName, "animation", onStart, onEnd); } /** @@ -669,9 +676,16 @@ export async function whenAnimationDone(targetEl: HTMLElement, animationName: st * * @param targetEl The element to watch for the transition to complete. * @param transitionProp The name of the transition to watch for completion. + * @param onStart A callback to run when the transition starts. + * @param onEnd A callback to run when the transition ends or is canceled. */ -export async function whenTransitionDone(targetEl: HTMLElement, transitionProp: string): Promise { - return whenTransitionOrAnimationDone(targetEl, transitionProp, "transition"); +export async function whenTransitionDone( + targetEl: HTMLElement, + transitionProp: string, + onStart?: () => void, + onEnd?: () => void, +): Promise { + return whenTransitionOrAnimationDone(targetEl, transitionProp, "transition", onStart, onEnd); } type TransitionOrAnimation = "transition" | "animation"; @@ -683,11 +697,15 @@ type TransitionOrAnimationEvent = TransitionEvent | AnimationEvent; * @param targetEl The element to watch for the transition or animation to complete. * @param transitionPropOrAnimationName The transition or animation property to watch for completion. * @param type The type of property to watch for completion. Defaults to "transition". + * @param onStart A callback to run when the transition or animation starts. + * @param onEnd A callback to run when the transition or animation ends or is canceled. */ export async function whenTransitionOrAnimationDone( targetEl: HTMLElement, transitionPropOrAnimationName: string, type: TransitionOrAnimation, + onStart?: () => void, + onEnd?: () => void, ): Promise { const style = window.getComputedStyle(targetEl); const allDurations = type === "transition" ? style.transitionDuration : style.animationDuration; @@ -702,8 +720,14 @@ export async function whenTransitionOrAnimationDone( so we fall back to it if there's no matching prop duration */ allDurationsArray[0]; + function startEndImmediately(): void { + onStart?.(); + onEnd?.(); + } + if (duration === "0s") { - return Promise.resolve(); + startEndImmediately(); + return; } const startEvent = type === "transition" ? "transitionstart" : "animationstart"; @@ -713,29 +737,32 @@ export async function whenTransitionOrAnimationDone( return new Promise((resolve) => { const fallbackTimeoutId = window.setTimeout( (): void => { - targetEl.removeEventListener(startEvent, onStart); - targetEl.removeEventListener(endEvent, onEndOrCancel); - targetEl.removeEventListener(cancelEvent, onEndOrCancel); + targetEl.removeEventListener(startEvent, onTransitionOrAnimationStart); + targetEl.removeEventListener(endEvent, onTransitionOrAnimationEndOrCancel); + targetEl.removeEventListener(cancelEvent, onTransitionOrAnimationEndOrCancel); + startEndImmediately(); resolve(); }, parseFloat(duration) * 1000, ); - targetEl.addEventListener(startEvent, onStart); - targetEl.addEventListener(endEvent, onEndOrCancel); - targetEl.addEventListener(cancelEvent, onEndOrCancel); + targetEl.addEventListener(startEvent, onTransitionOrAnimationStart); + targetEl.addEventListener(endEvent, onTransitionOrAnimationEndOrCancel); + targetEl.addEventListener(cancelEvent, onTransitionOrAnimationEndOrCancel); - function onStart(event: TransitionOrAnimationEvent): void { + function onTransitionOrAnimationStart(event: TransitionOrAnimationEvent): void { if (event.target === targetEl && getTransitionOrAnimationName(event) === transitionPropOrAnimationName) { window.clearTimeout(fallbackTimeoutId); - targetEl.removeEventListener(startEvent, onStart); + targetEl.removeEventListener(startEvent, onTransitionOrAnimationStart); + onStart?.(); } } - function onEndOrCancel(event: TransitionOrAnimationEvent): void { + function onTransitionOrAnimationEndOrCancel(event: TransitionOrAnimationEvent): void { if (event.target === targetEl && getTransitionOrAnimationName(event) === transitionPropOrAnimationName) { - targetEl.removeEventListener(endEvent, onEndOrCancel); - targetEl.removeEventListener(cancelEvent, onEndOrCancel); + targetEl.removeEventListener(endEvent, onTransitionOrAnimationEndOrCancel); + targetEl.removeEventListener(cancelEvent, onTransitionOrAnimationEndOrCancel); + onEnd?.(); resolve(); } } diff --git a/packages/calcite-components/src/utils/openCloseComponent.spec.ts b/packages/calcite-components/src/utils/openCloseComponent.spec.ts new file mode 100644 index 00000000000..2e865c6ea4b --- /dev/null +++ b/packages/calcite-components/src/utils/openCloseComponent.spec.ts @@ -0,0 +1,67 @@ +import { createTransitionEventDispatcher, TransitionEventDispatcher } from "../tests/spec-helpers/transitionEvents"; +import { mockGetComputedStyleFor } from "../tests/spec-helpers/computedStyle"; +import * as openCloseComponent from "./openCloseComponent"; + +const { onToggleOpenCloseComponent } = openCloseComponent; + +describe("openCloseComponent", () => { + describe("toggleOpenCloseComponent", () => { + let dispatchTransitionEvent: TransitionEventDispatcher; + + beforeEach(() => { + jest.spyOn(openCloseComponent, "internalReadTask").mockImplementation((task) => task(1337)); + dispatchTransitionEvent = createTransitionEventDispatcher(); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + it("emits beforeOpen/beforeClose events when the transition starts and open/close events when the transition is done", async () => { + const transitionEl = window.document.createElement("div"); + const testProp = "opacity"; + const testDuration = "0.5s"; + const testTransition = `${testProp} ${testDuration} ease 0s`; + + transitionEl.style.transition = testTransition; + window.document.body.append(transitionEl); + mockGetComputedStyleFor(transitionEl, { + transition: testTransition, + transitionDuration: testDuration, + transitionProperty: testProp, + }); + + const emittedEvents: string[] = []; + const fakeOpenCloseComponent = { + el: document.createElement("div"), + open: true, + openTransitionProp: "opacity", + transitionEl, + onBeforeOpen: jest.fn(() => emittedEvents.push("beforeOpen")), + onOpen: jest.fn(() => emittedEvents.push("open")), + onBeforeClose: jest.fn(() => emittedEvents.push("beforeClose")), + onClose: jest.fn(() => emittedEvents.push("close")), + }; + + onToggleOpenCloseComponent(fakeOpenCloseComponent); + expect(emittedEvents).toEqual([]); + + dispatchTransitionEvent(transitionEl, "transitionstart", fakeOpenCloseComponent.openTransitionProp); + expect(emittedEvents).toEqual(["beforeOpen"]); + + dispatchTransitionEvent(transitionEl, "transitionend", fakeOpenCloseComponent.openTransitionProp); + expect(emittedEvents).toEqual(["beforeOpen", "open"]); + + fakeOpenCloseComponent.open = false; + + onToggleOpenCloseComponent(fakeOpenCloseComponent); + expect(emittedEvents).toEqual(["beforeOpen", "open"]); + + dispatchTransitionEvent(transitionEl, "transitionstart", fakeOpenCloseComponent.openTransitionProp); + expect(emittedEvents).toEqual(["beforeOpen", "open", "beforeClose"]); + + dispatchTransitionEvent(transitionEl, "transitionend", fakeOpenCloseComponent.openTransitionProp); + expect(emittedEvents).toEqual(["beforeOpen", "open", "beforeClose", "close"]); + }); + }); +}); diff --git a/packages/calcite-components/src/utils/openCloseComponent.ts b/packages/calcite-components/src/utils/openCloseComponent.ts index 71c3ac78def..3ee33162dc7 100644 --- a/packages/calcite-components/src/utils/openCloseComponent.ts +++ b/packages/calcite-components/src/utils/openCloseComponent.ts @@ -1,6 +1,11 @@ import { readTask } from "@stencil/core"; import { whenTransitionDone } from "./dom"; +/** + * Exported for testing purposes only + */ +export const internalReadTask = readTask; + /** * Defines interface for components with open/close public emitter. * All implementations of this interface must handle the following events: `beforeOpen`, `open`, `beforeClose`, `close`. @@ -81,20 +86,28 @@ function isOpen(component: OpenCloseComponent): boolean { * @param component - OpenCloseComponent uses `open` prop to emit (before)open/close. */ export function onToggleOpenCloseComponent(component: OpenCloseComponent): void { - readTask(async (): Promise => { + internalReadTask((): void => { if (!component.transitionEl) { return; } - await whenTransitionDone(component.transitionEl, component.openTransitionProp); - - if (isOpen(component)) { - component.onBeforeOpen(); - component.onOpen(); - return; - } - - component.onBeforeClose(); - component.onClose(); + whenTransitionDone( + component.transitionEl, + component.openTransitionProp, + () => { + if (isOpen(component)) { + component.onBeforeOpen(); + } else { + component.onBeforeClose(); + } + }, + () => { + if (isOpen(component)) { + component.onOpen(); + } else { + component.onClose(); + } + }, + ); }); }