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,
+164 -28
View File
@@ -18,56 +18,192 @@
flex-shrink: 0;
}
.portfolioIcon {
color: var(--mantine-color-blue-7);
}
.cardsIcon {
color: var(--mantine-color-grape-7);
& svg {
overflow: visible;
}
}
.rubles {
pointer-events: none;
color: var(--mantine-color-green-8);
stroke: currentColor;
& path {
opacity: 0;
--flight-x: -7px;
--flight-rotation: -20deg;
--flight-delay: 0ms;
}
& path:nth-child(2) {
--flight-x: 0px;
--flight-rotation: 8deg;
--flight-delay: 120ms;
}
& path:nth-child(3) {
--flight-x: 7px;
--flight-rotation: 24deg;
--flight-delay: 240ms;
}
}
.stocksIcon {
color: var(--mantine-color-green-8);
}
.bondsIcon {
color: var(--mantine-color-orange-8);
}
.settingsIcon {
color: var(--mantine-color-gray-7);
}
.portfolioRuble {
stroke: var(--mantine-color-green-8);
opacity: 0;
}
.stockArrow {
transform-origin: 21px 5px;
}
.topSlider,
.bottomSlider {
fill: var(--mantine-color-body);
}
@media (prefers-reduced-motion: no-preference) {
.drawStroke {
.navLink {
transition: background-color 180ms ease-in-out;
}
.drawStroke,
.couponLines path {
stroke-dasharray: 1;
}
.topSlider {
--slider-offset: -3px;
--slider-offset: 3px;
--slider-delay: 0ms;
}
.bottomSlider {
--slider-offset: 3px;
--slider-offset: -3px;
--slider-delay: 160ms;
}
.navLink:focus-visible {
.portfolioIcon {
animation: portfolio-sway 650ms ease-in-out;
.couponLines path:nth-child(2) {
--line-delay: 140ms;
}
.couponLines path:nth-child(3) {
--line-delay: 280ms;
}
svg[data-icon-animating] {
.rubles path {
animation: ruble-fly 800ms ease-out var(--flight-delay) both;
}
.portfolioHandle {
animation: handle-lift 1200ms ease-in-out;
}
.portfolioLid {
animation: lid-open 1200ms ease-in-out;
}
.portfolioRuble {
animation: portfolio-ruble-reveal 1200ms ease-in-out;
}
.stockArrow {
animation: arrow-pop 300ms ease-out 400ms both;
}
.couponLines path {
animation: stroke-draw 300ms ease-out var(--line-delay, 0ms) both;
}
.drawStroke {
animation: stroke-draw 650ms ease-in-out;
animation: stroke-draw 450ms ease-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out;
}
}
@media (hover: hover) {
.navLink:hover {
.portfolioIcon {
animation: portfolio-sway 650ms ease-in-out;
}
.drawStroke {
animation: stroke-draw 650ms ease-in-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out;
}
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
}
}
}
@keyframes portfolio-sway {
@keyframes ruble-fly {
0% {
opacity: 0;
transform: translate(0, 4px);
}
20%,
50% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(var(--flight-x), -10px) rotate(var(--flight-rotation));
}
}
@keyframes handle-lift {
0%,
100% {
transform: rotate(0);
transform: translateY(0);
}
30% {
transform: translateY(-1px) rotate(-10deg);
20% {
transform: translateY(-3px);
}
65% {
transform: rotate(7deg);
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes lid-open {
0%,
15%,
100% {
transform: translateY(0);
}
40%,
75% {
transform: translateY(-3px);
}
}
@keyframes portfolio-ruble-reveal {
0%,
25%,
100% {
opacity: 0;
transform: translateY(2px);
}
40%,
75% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes arrow-pop {
0% {
opacity: 0;
transform: scale(0.7);
}
60% {
opacity: 1;
transform: scale(1.2);
}
100% {
opacity: 1;
transform: scale(1);
}
}
+38 -13
View File
@@ -2,6 +2,7 @@ import { AppShell as MantineAppShell, NavLink, Text } from "@mantine/core";
import type { FC, ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
import { bindIconAnimation } from "../lib";
import classes from "./AppShell.module.css";
type AppShellProps = {
@@ -18,52 +19,75 @@ const AppShell: FC<AppShellProps> = (props) => {
label: t("navigation.accounts"),
iconClassName: classes.portfolioIcon,
icon: (
<path d="M8 6V4h8v2M3 10h18M9 10v3h6v-3M5 6h14a2 2 0 0 1 2 2v11H3V8a2 2 0 0 1 2-2Z" />
<>
<path d="M3 10v9h18v-9" />
<path className={classes.portfolioHandle} d="M8 6V4h8v2" />
<path
className={classes.portfolioLid}
d="M3 10V8a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v2H3Zm6 0v3h6v-3"
/>
<path
className={classes.portfolioRuble}
d="M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4"
/>
</>
),
},
{
to: "/cards",
label: t("navigation.cards"),
icon: <path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />,
iconClassName: classes.cardsIcon,
icon: (
<>
<path d="M3 5h18v14H3V5Zm0 4h18M6 15h4" />
<g className={classes.rubles}>
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
<path d="M10 12V4h3a2 2 0 0 1 0 4H9m0 2h4" />
</g>
</>
),
},
{
to: "/stocks",
label: t("navigation.stocks"),
iconClassName: classes.stocksIcon,
icon: (
<>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
pathLength={1}
d="M7 14l5-5 4 3 5-7M16 5h5v5"
d="M7 14l5-5 4 3 5-7"
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</>
),
},
{
to: "/bonds",
label: t("navigation.bonds"),
iconClassName: classes.bondsIcon,
icon: (
<>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<path
className={classes.drawStroke}
pathLength={1}
d="M9 7h6M9 11h6M9 15h3"
/>
<g className={classes.couponLines}>
<path pathLength={1} d="M9 7h6" />
<path pathLength={1} d="M9 11h6" />
<path pathLength={1} d="M9 15h3" />
</g>
</>
),
},
{
to: "/settings",
label: t("navigation.settings"),
iconClassName: classes.settingsIcon,
icon: (
<>
<path className={classes.topSlider} d="M4 7h9m4 0h3M13 4v6h4V4h-4Z" />
<path
className={classes.bottomSlider}
d="M4 17h3m4 0h9M7 14v6h4v-6H7Z"
/>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</>
),
},
@@ -91,6 +115,7 @@ const AppShell: FC<AppShellProps> = (props) => {
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
>
<svg
ref={bindIconAnimation}
width={20}
height={20}
viewBox="0 0 24 24"