Skip to content
Merged
42 changes: 30 additions & 12 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"@types/estree": "1.0.5",
"@types/jest": "29.5.12",
"@types/jest-axe": "3.5.9",
"@types/jsdom": "21.1.6",
"@types/lodash-es": "4.17.12",
"@types/node": "^20.12.7",
"@types/prettier": "2.7.3",
Expand Down
153 changes: 146 additions & 7 deletions packages/calcite-components/src/utils/dom.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,27 @@
import { JSDOM } from "jsdom";
import { ModeName } from "../../src/components/interfaces";
import { html } from "../../support/formatting";
import {
ensureId,
focusElementInGroup,
getElementProp,
getModeName,
getShadowRootNode,
getSlotted,
isBefore,
isKeyboardTriggeredClick,
isPrimaryPointerButton,
setRequestedIcon,
slotChangeGetAssignedElements,
slotChangeHasAssignedElement,
toAriaBoolean,
getShadowRootNode,
slotChangeGetTextContent,
slotChangeGetAssignedNodes,
slotChangeGetTextContent,
slotChangeHasAssignedElement,
slotChangeHasAssignedNode,
slotChangeHasTextContent,
slotChangeHasContent,
isBefore,
isKeyboardTriggeredClick,
slotChangeHasTextContent,
toAriaBoolean,
whenAnimationDone,
whenTransitionDone,
} from "./dom";
import { guidPattern } from "./guid.spec";

Expand Down Expand Up @@ -607,4 +610,140 @@ describe("dom", () => {
expect(isKeyboardTriggeredClick(event)).toBe(false);
});
});

async function promiseState(
promise: Promise<any>,
): Promise<{ status: "fulfilled" | "rejected"; value?: any; reason: any }> {
const pendingState = { status: "pending" };

return Promise.race([promise, pendingState]).then(
(value) => (value === pendingState ? value : { status: "fulfilled", value }),
(reason) => ({ status: "rejected", reason }),
);
}

describe("whenTransitionDone", () => {
let dispatchTransitionEvent: (
element: HTMLElement,
type: "transitionstart" | "transitionend",
propertyName: string,
) => void;

beforeEach(() => {
// we clobber Stencil's custom Mock document implementation
Comment thread
jcfranco marked this conversation as resolved.
const { window: win } = new JSDOM();

// make window references use JSDOM (which is a subset, hence the type cast)
window = win as any as Window & typeof globalThis;

// 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;
}
}

dispatchTransitionEvent = (
element: HTMLElement,
type: "transitionstart" | "transitionend",
propertyName: string,
): void => {
element.dispatchEvent(new TransitionEvent(type, { propertyName }));
};
});

it("should return a promise that resolves after the transition", async () => {
const element = window.document.createElement("div");
const testProp = "opacity";
const testDuration = "0.5s";
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
Comment thread
jcfranco marked this conversation as resolved.
window.getComputedStyle = jest.fn().mockReturnValue({
transition: testTransition,
transitionDuration: testDuration,
transitionProperty: testProp,
});
window.document.body.append(element);

const promise = whenTransitionDone(element, "opacity");
element.style.opacity = "0";
expect(await promiseState(promise)).toHaveProperty("status", "pending");

dispatchTransitionEvent(element, "transitionstart", "opacity");
expect(await promiseState(promise)).toHaveProperty("status", "pending");

dispatchTransitionEvent(element, "transitionend", "opacity");
expect(await promiseState(promise)).toHaveProperty("status", "pending");

expect(await promiseState(promise)).toHaveProperty("status", "fulfilled");
});
});

describe("whenAnimationDone", () => {
let dispatchAnimationEvent: (
element: HTMLElement,
type: "animationstart" | "animationend",
animationName: string,
) => void;

beforeEach(() => {
// we clobber Stencil's custom Mock document implementation
const { window: win } = new JSDOM();

// make window references use JSDOM (which is a subset, hence the type cast)
window = win as any as Window & typeof globalThis;

// 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;
}
}

dispatchAnimationEvent = (
element: HTMLElement,
type: "animationstart" | "animationend",
animationName: string,
): void => {
element.dispatchEvent(new AnimationEvent(type, { animationName }));
};
});

it("should return a promise that resolves after the animation", async () => {
const element = window.document.createElement("div");
const testAnimationName = "fade";
const testDuration = "0.5s";

element.style.animation = `${testAnimationName} ${testDuration} ease 0s`;
window.document.body.append(element);

const promise = whenAnimationDone(element, testAnimationName);
element.style.animationName = "none";
expect(await promiseState(promise)).toHaveProperty("status", "pending");

dispatchAnimationEvent(element, "animationstart", testAnimationName);
expect(await promiseState(promise)).toHaveProperty("status", "pending");

dispatchAnimationEvent(element, "animationend", testAnimationName);
expect(await promiseState(promise)).toHaveProperty("status", "pending");

expect(await promiseState(promise)).toHaveProperty("status", "fulfilled");
});
});
});
62 changes: 50 additions & 12 deletions packages/calcite-components/src/utils/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,11 +661,41 @@ export function isBefore(a: HTMLElement, b: HTMLElement): boolean {
* @param animationName The name of the animation to watch for completion.
*/
export async function whenAnimationDone(targetEl: HTMLElement, animationName: string): Promise<void> {
const { animationDuration: allDurations, animationName: allNames } = getComputedStyle(targetEl);
return whenTransitionOrAnimationDone(targetEl, animationName, "animation");
}

/**
* This util helps determine when a transition has completed.
*
* @param targetEl The element to watch for the transition to complete.
* @param transitionProp The name of the transition to watch for completion.
*/
export async function whenTransitionDone(targetEl: HTMLElement, transitionProp: string): Promise<void> {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't we just use onTransitionEnd on a VNode in most cases? Should we specify that?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can tweak the name and doc to better explain its usage.

return whenTransitionOrAnimationDone(targetEl, transitionProp, "transition");
}

type TransitionOrAnimation = "transition" | "animation";
type TransitionOrAnimationEvent = TransitionEvent | AnimationEvent;

/**
* This util helps determine when a transition has completed.
*
* @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".
*/
export async function whenTransitionOrAnimationDone(
targetEl: HTMLElement,
transitionPropOrAnimationName: string,
type: TransitionOrAnimation,
): Promise<void> {
const style = window.getComputedStyle(targetEl);
const allDurations = type === "transition" ? style.transitionDuration : style.animationDuration;
const allProps = type === "transition" ? style.transitionProperty : style.animationName;

const allDurationsArray = allDurations.split(",");
const allPropsArray = allNames.split(",");
const propIndex = allPropsArray.indexOf(animationName);
const allPropsArray = allProps.split(",");
const propIndex = allPropsArray.indexOf(transitionPropOrAnimationName);
const duration =
allDurationsArray[propIndex] ??
/* Safari will have a single duration value for the shorthand prop when multiple, separate names/props are defined,
Expand All @@ -676,12 +706,12 @@ export async function whenAnimationDone(targetEl: HTMLElement, animationName: st
return Promise.resolve();
}

const startEvent = "animationstart";
const endEvent = "animationend";
const cancelEvent = "animationcancel";
const startEvent = type === "transition" ? "transitionstart" : "animationstart";
const endEvent = type === "transition" ? "transitionend" : "animationend";
const cancelEvent = type === "transition" ? "transitioncancel" : "animationcancel";

return new Promise<void>((resolve) => {
const fallbackTimeoutId = setTimeout(
const fallbackTimeoutId = window.setTimeout(
(): void => {
targetEl.removeEventListener(startEvent, onStart);
targetEl.removeEventListener(endEvent, onEndOrCancel);
Expand All @@ -695,19 +725,27 @@ export async function whenAnimationDone(targetEl: HTMLElement, animationName: st
targetEl.addEventListener(endEvent, onEndOrCancel);
targetEl.addEventListener(cancelEvent, onEndOrCancel);

function onStart(event: AnimationEvent): void {
if (event.animationName === animationName && event.target === targetEl) {
clearTimeout(fallbackTimeoutId);
function onStart(event: TransitionOrAnimationEvent): void {
if (event.target === targetEl && getTransitionOrAnimationName(event) === transitionPropOrAnimationName) {
window.clearTimeout(fallbackTimeoutId);
targetEl.removeEventListener(startEvent, onStart);
}
}

function onEndOrCancel(event: AnimationEvent): void {
if (event.animationName === animationName && event.target === targetEl) {
function onEndOrCancel(event: TransitionOrAnimationEvent): void {
if (event.target === targetEl && getTransitionOrAnimationName(event) === transitionPropOrAnimationName) {
targetEl.removeEventListener(endEvent, onEndOrCancel);
targetEl.removeEventListener(cancelEvent, onEndOrCancel);
resolve();
}
}
});
}

function isTransitionEvent(event: TransitionOrAnimationEvent): event is TransitionEvent {
return "propertyName" in event;
}

function getTransitionOrAnimationName(event: TransitionOrAnimationEvent): string {
return isTransitionEvent(event) ? event.propertyName : event.animationName;
}
Loading