feat(ui): animate navigation and operation icons

This commit is contained in:
2026-09-12 21:31:57 +03:00
parent d3ac8c242d
commit 8e58c016d2
8 changed files with 764 additions and 54 deletions
@@ -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");
});
+68
View File
@@ -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
View File
@@ -1,3 +1,4 @@
export { bindIconAnimation } from "./bind-icon-animation";
export {
formatDate,
formatDecimal,