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,
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user