Skip to content

Commit

Permalink
fix: fix regression causing open/close events from emitting in proper…
Browse files Browse the repository at this point in the history
… order (#9560)

**Related Issue:** #9559

## Summary

This fixes `openCloseComponent.onToggleOpenCloseComponent` to emit
beforeOpen/Close events when the associated transition starts and emits
open/close when the transition ends.

This regression was introduced by
#9341 and would cause
`beforeOpen`/`open` or `beforeClose`/`close` to emit immediately after
another after the transition was done.

### Notable changes

* adds spec test to add coverage for `onToggleOpenCloseComponent`
* enhances `whenAnimationDone`/`whenTransitionDone` to accept callbacks
for both start and end phases
* exposes `readTask` from `openCloseComponent` to allow for stubbing
(stubbing Stencil's utility is not possible because the core module
getters are non-configurable)
* extracts animation/transition helpers for spec tests to individual
modules
  • Loading branch information
jcfranco authored Jun 12, 2024
1 parent 86eefb0 commit fa5b415
Show file tree
Hide file tree
Showing 7 changed files with 345 additions and 103 deletions.
Original file line number Diff line number Diff line change
@@ -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 }));
};
}
Original file line number Diff line number Diff line change
@@ -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<CSSStyleDeclaration>): void {
jest.spyOn(window, "getComputedStyle").mockImplementation((el: Element): CSSStyleDeclaration => {
if (el === element) {
return fakeComputedStyle as CSSStyleDeclaration;
}
});
}
Original file line number Diff line number Diff line change
@@ -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 }));
};
}
223 changes: 147 additions & 76 deletions packages/calcite-components/src/utils/dom.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -23,7 +26,6 @@ import {
whenAnimationDone,
whenTransitionDone,
} from "./dom";
import { guidPattern } from "./guid.spec";

describe("dom", () => {
describe("getElementProp()", () => {
Expand Down Expand Up @@ -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<any, any, any>;
let onEndCallback: jest.Mock<any, any, any>;

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<any, any, any>;
let onEndCallback: jest.Mock<any, any, any>;

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();
});
});
});
Loading

0 comments on commit fa5b415

Please sign in to comment.