feat(ui): animate navigation and operation icons
This commit is contained in:
@@ -0,0 +1,87 @@
|
||||
import { fireEvent, render, waitFor } from "@testing-library/react";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { bindIconAnimation } from "./bind-icon-animation";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("finishes every delayed part after leaving and does not restart on quick re-entry", async () => {
|
||||
vi.spyOn(window, "matchMedia").mockReturnValue({
|
||||
matches: true,
|
||||
} as MediaQueryList);
|
||||
const { container, getByRole } = render(
|
||||
<button type="button">
|
||||
<svg ref={bindIconAnimation} aria-hidden="true" />
|
||||
Category
|
||||
</button>,
|
||||
);
|
||||
const icon = container.querySelector("svg");
|
||||
if (!icon) throw new Error("Missing icon");
|
||||
const target = getByRole("button");
|
||||
let finishFirst!: () => void;
|
||||
let finishLast!: () => void;
|
||||
const getAnimations = vi.fn(() => [
|
||||
{
|
||||
finished: new Promise<void>((resolve) => {
|
||||
finishFirst = resolve;
|
||||
}),
|
||||
},
|
||||
{
|
||||
finished: new Promise<void>((resolve) => {
|
||||
finishLast = resolve;
|
||||
}),
|
||||
},
|
||||
]);
|
||||
Object.defineProperty(icon, "getAnimations", { value: getAnimations });
|
||||
fireEvent.mouseEnter(target);
|
||||
fireEvent.mouseLeave(target);
|
||||
expect(icon).toHaveAttribute("data-icon-animating");
|
||||
finishFirst();
|
||||
await Promise.resolve();
|
||||
expect(icon).toHaveAttribute("data-icon-animating");
|
||||
fireEvent.mouseEnter(target);
|
||||
expect(getAnimations).toHaveBeenCalledTimes(1);
|
||||
finishLast();
|
||||
await Promise.resolve();
|
||||
expect(icon).toHaveAttribute("data-icon-animating");
|
||||
fireEvent.mouseLeave(target);
|
||||
await waitFor(() => expect(icon).not.toHaveAttribute("data-icon-animating"));
|
||||
fireEvent.mouseEnter(target);
|
||||
expect(getAnimations).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps playing after keyboard blur and releases cancelled animations", async () => {
|
||||
const { container, getByRole, unmount } = render(
|
||||
<button type="button">
|
||||
<svg ref={bindIconAnimation} aria-hidden="true" />
|
||||
Category
|
||||
</button>,
|
||||
);
|
||||
const icon = container.querySelector("svg");
|
||||
if (!icon) throw new Error("Missing icon");
|
||||
const target = getByRole("button");
|
||||
// JSDOM does not model the browser keyboard-focus modality.
|
||||
vi.spyOn(target, "matches").mockImplementation(
|
||||
(selector) => selector === ":focus-visible",
|
||||
);
|
||||
let cancel!: () => void;
|
||||
Object.defineProperty(icon, "getAnimations", {
|
||||
value: () => [
|
||||
{
|
||||
finished: new Promise<void>((_resolve, reject) => {
|
||||
cancel = () => reject(new Error("cancelled"));
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
target.focus();
|
||||
expect(icon).toHaveAttribute("data-icon-animating");
|
||||
target.blur();
|
||||
expect(icon).toHaveAttribute("data-icon-animating");
|
||||
cancel();
|
||||
await waitFor(() => expect(icon).not.toHaveAttribute("data-icon-animating"));
|
||||
target.focus();
|
||||
unmount();
|
||||
cancel();
|
||||
await Promise.resolve();
|
||||
expect(icon).not.toHaveAttribute("data-icon-animating");
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
// React ref cleanup keeps native animation lifetimes tied to the mounted icon.
|
||||
const bindIconAnimation = (icon: SVGSVGElement | null) => {
|
||||
if (!icon) return;
|
||||
const target = icon.closest<HTMLElement>("a, button") ?? icon.parentElement;
|
||||
if (!target) return;
|
||||
let hovered = false;
|
||||
let focused = false;
|
||||
let running = false;
|
||||
let disposed = false;
|
||||
|
||||
const release = () => {
|
||||
if (!running && !hovered && !focused) {
|
||||
icon.removeAttribute("data-icon-animating");
|
||||
}
|
||||
};
|
||||
const start = () => {
|
||||
if (icon.hasAttribute("data-icon-animating")) return;
|
||||
icon.setAttribute("data-icon-animating", "");
|
||||
running = true;
|
||||
// Includes staggered/delayed children and finite iterations, without timers.
|
||||
const animations = icon.getAnimations({ subtree: true });
|
||||
void Promise.allSettled(
|
||||
animations.map((animation) => animation.finished),
|
||||
).then(() => {
|
||||
if (disposed) return;
|
||||
running = false;
|
||||
release();
|
||||
});
|
||||
};
|
||||
const enter = () => {
|
||||
if (!window.matchMedia("(hover: hover)").matches) return;
|
||||
hovered = true;
|
||||
start();
|
||||
};
|
||||
const leave = () => {
|
||||
hovered = false;
|
||||
release();
|
||||
};
|
||||
const focus = () => {
|
||||
focused =
|
||||
target.matches(":focus-visible") ||
|
||||
!!target.querySelector(":focus-visible");
|
||||
if (focused) start();
|
||||
};
|
||||
const blur = (event: FocusEvent) => {
|
||||
if (
|
||||
event.relatedTarget instanceof Node &&
|
||||
target.contains(event.relatedTarget)
|
||||
)
|
||||
return;
|
||||
focused = false;
|
||||
release();
|
||||
};
|
||||
target.addEventListener("mouseenter", enter);
|
||||
target.addEventListener("mouseleave", leave);
|
||||
target.addEventListener("focusin", focus);
|
||||
target.addEventListener("focusout", blur);
|
||||
return () => {
|
||||
disposed = true;
|
||||
target.removeEventListener("mouseenter", enter);
|
||||
target.removeEventListener("mouseleave", leave);
|
||||
target.removeEventListener("focusin", focus);
|
||||
target.removeEventListener("focusout", blur);
|
||||
icon.removeAttribute("data-icon-animating");
|
||||
};
|
||||
};
|
||||
|
||||
export { bindIconAnimation };
|
||||
@@ -1,3 +1,4 @@
|
||||
export { bindIconAnimation } from "./bind-icon-animation";
|
||||
export {
|
||||
formatDate,
|
||||
formatDecimal,
|
||||
|
||||
Reference in New Issue
Block a user