refactor: enforce frontend view model boundaries
Frontend verify / commits (pull_request) Successful in 28s
Frontend verify / security (pull_request) Successful in 10s
Frontend verify / verify (pull_request) Successful in 8m19s

This commit is contained in:
2026-09-28 18:48:23 +03:00
parent 5b69003516
commit ca0ad4f824
287 changed files with 8558 additions and 4538 deletions
+5 -2
View File
@@ -10,9 +10,12 @@
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"./styles.css": "./dist/index.css"
},
"sideEffects": false,
"sideEffects": [
"**/*.css"
],
"scripts": {
"build": "vite build && tsc -p tsconfig.json"
},
+5
View File
@@ -17,6 +17,7 @@ const meta = {
} satisfies Meta<typeof AddIcon>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Decorative: Story = {
@@ -25,9 +26,11 @@ export const Decorative: Story = {
<button aria-label="Добавить пример" type="button">
<AddIcon size={20} strokeWidth={2} />
</button>
<button aria-label="Изменить пример" type="button">
<EditIcon size={18} />
</button>
<button aria-label="Удалить пример" type="button">
<DeleteIcon size={18} />
</button>
@@ -39,7 +42,9 @@ export const Meaningful: Story = {
render: () => (
<div style={{ color: "#2563eb", display: "flex", gap: 16 }}>
<AddIcon aria-hidden={false} aria-label="Добавление" role="img" />
<EditIcon aria-hidden={false} aria-label="Редактирование" role="img" />
<DeleteIcon aria-hidden={false} aria-label="Удаление" role="img" />
</div>
),
+1
View File
@@ -12,6 +12,7 @@ describe("icon accessibility", () => {
<Icon />
</button>,
);
expect(screen.queryByRole("img")).not.toBeInTheDocument();
expect(
// eslint-disable-next-line testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
@@ -0,0 +1,365 @@
.icon {
overflow: visible;
& path {
--motion: none;
transform-box: fill-box;
transform-origin: center;
}
}
.deposit path:nth-child(-n + 2) {
transform-box: view-box;
transform-origin: 12px 9px;
--motion: bank-operation-coin-turn 1000ms ease-in-out both;
}
.repayment path:last-child {
--motion: bank-operation-ruble-in 1000ms ease-out both;
}
.topup path:last-child {
--motion: bank-operation-plus-in 900ms ease-out both;
}
.transfer path {
--shift: 3px;
--motion: bank-operation-exchange 700ms ease-in-out both;
&:last-child {
--shift: -3px;
}
}
.growth {
& path:nth-child(2) {
stroke-dasharray: 1;
--motion: bank-operation-draw 450ms ease-out both;
}
& path:last-child {
--motion: bank-operation-arrow-rise 350ms ease-out 400ms both;
}
}
.server {
& path:nth-child(3) {
--motion: bank-operation-blink 500ms ease-in-out both;
}
& path:nth-child(4) {
--motion: bank-operation-blink 500ms ease-in-out 220ms both;
}
}
.basket {
& path:not(:last-child) {
--motion: bank-operation-settle 850ms ease-in-out both;
}
& path:last-child {
opacity: 0;
fill: currentColor;
--motion: bank-operation-grocery-drop 850ms ease-out both;
}
}
.wine {
--motion: bank-operation-cheers 1100ms ease-in-out both;
transform-origin: 12px 21px;
& path:nth-child(2) {
fill: currentColor;
fill-opacity: 0.2;
transform-origin: bottom;
--motion: bank-operation-fill-glass 1100ms ease-out both;
}
}
.coffee {
& path:nth-child(3),
& path:nth-child(4) {
--motion: bank-operation-steam 1000ms ease-out var(--delay, 0ms) both;
}
& path:nth-child(4) {
--delay: 180ms;
}
}
.grid path {
--x: -1.5px;
--y: -1.5px;
--turn: 0deg;
--motion: bank-operation-rearrange 850ms ease-in-out both;
&:nth-child(2) {
--x: 1.5px;
--turn: 90deg;
}
&:nth-child(3) {
--y: 1.5px;
}
&:nth-child(4) {
--x: 1.5px;
--y: 1.5px;
}
}
.wifi path {
--motion: bank-operation-signal 450ms ease-out var(--delay, 0ms) both;
&:nth-child(1) {
--delay: 360ms;
}
&:nth-child(2) {
--delay: 240ms;
}
&:nth-child(3) {
--delay: 120ms;
}
}
.car {
--motion: bank-operation-drive 1100ms ease-in-out both;
& path:nth-child(-n + 3) {
--motion: bank-operation-suspension 275ms ease-in-out 4 both;
}
& path:nth-last-child(-n + 2) {
--motion: bank-operation-wheel-roll 1100ms ease-in-out both;
}
}
@media (prefers-reduced-motion: no-preference) {
.icon[data-icon-animating] {
animation: var(--motion, none);
& path {
animation: var(--motion, none);
}
}
}
@keyframes :global(bank-operation-coin-turn) {
0%,
100% {
transform: scaleX(1);
}
35%,
65% {
transform: scaleX(0.12);
}
50% {
transform: scaleX(-1);
}
}
@keyframes :global(bank-operation-settle) {
0%,
30%,
100% {
transform: translateY(0);
}
45% {
transform: translateY(1.5px);
}
65% {
transform: translateY(-0.5px);
}
}
@keyframes :global(bank-operation-ruble-in) {
0% {
opacity: 0;
transform: translateY(-9px) scale(0.7);
}
25% {
opacity: 1;
}
65% {
transform: translateY(0) scale(1.15);
}
80% {
transform: scale(0.95);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes :global(bank-operation-plus-in) {
0% {
opacity: 0;
transform: translateY(-5px) scale(0.5);
}
55% {
opacity: 1;
transform: translateY(0) scale(1.3);
}
75% {
transform: scale(0.9);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes :global(bank-operation-exchange) {
0%,
100% {
transform: translateX(0);
}
45% {
transform: translateX(var(--shift));
}
}
@keyframes :global(bank-operation-draw) {
from {
stroke-dashoffset: 1;
}
to {
stroke-dashoffset: 0;
}
}
@keyframes :global(bank-operation-arrow-rise) {
0%,
100% {
transform: translateY(0);
}
45% {
transform: translateY(-2px);
}
}
@keyframes :global(bank-operation-blink) {
0%,
100% {
stroke-width: 1.6;
}
45% {
stroke: var(--mantine-color-green-6);
stroke-width: 3;
}
}
@keyframes :global(bank-operation-grocery-drop) {
0% {
opacity: 0;
transform: translateY(-5px);
}
20% {
opacity: 1;
}
55% {
opacity: 1;
transform: translateY(7px);
}
80%,
100% {
opacity: 0;
transform: translateY(7px);
}
}
@keyframes :global(bank-operation-fill-glass) {
0% {
transform: scaleY(0.05);
}
45%,
75% {
transform: scaleY(1);
fill-opacity: 0.5;
}
100% {
transform: scaleY(1);
fill-opacity: 0.2;
}
}
@keyframes :global(bank-operation-cheers) {
0%,
40%,
100% {
transform: rotate(0);
}
65% {
transform: rotate(8deg);
}
}
@keyframes :global(bank-operation-steam) {
0% {
opacity: 0;
transform: translateY(2px);
}
25% {
opacity: 1;
}
70% {
opacity: 0;
transform: translateY(-3px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@keyframes :global(bank-operation-rearrange) {
0%,
100% {
transform: translate(0, 0) rotate(0);
}
50% {
transform: translate(var(--x), var(--y)) rotate(var(--turn));
}
}
@keyframes :global(bank-operation-signal) {
0% {
opacity: 0.1;
}
100% {
opacity: 1;
}
}
@keyframes :global(bank-operation-drive) {
0% {
opacity: 0;
transform: translateX(-4px);
}
20% {
opacity: 1;
}
80% {
opacity: 1;
transform: translateX(3px);
}
90% {
opacity: 0;
transform: translateX(4px);
}
91% {
opacity: 0;
transform: translateX(0);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
@keyframes :global(bank-operation-suspension) {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-0.5px);
}
}
@keyframes :global(bank-operation-wheel-roll) {
from {
transform: rotate(0);
}
to {
transform: rotate(360deg);
}
}
@@ -0,0 +1,86 @@
import type { FC } from "react";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "./OperationIcon.module.css";
const paths = {
basket:
"M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3 M10 6a2 2 0 1 0 4 0a2 2 0 1 0-4 0",
car:
"M4 16H2v-4l3-1 3-4h6l4 4 4 1v4h-2 M8 16h8 M6 11h12m-6-4v4 M4 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4 M16 16a2 2 0 1 0 4 0a2 2 0 1 0-4 0m2-2v4m-2-2h4",
cashWithdrawal: "M4 10V3h16v7 M7 7h10v14H7V7Z M7 11h10 M10 16h4",
coffee:
"M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 6c-2-1 2-2 0-4 M12 6c-2-1 2-2 0-4 M3 22h15",
deposit:
"M6 9a6 6 0 1 0 12 0a6 6 0 1 0-12 0 M10.5 12V6h2a1.5 1.5 0 0 1 0 3h-3m0 1.5h3 M4 16c0 4 16 4 16 0 M4 19c0 4 16 4 16 0",
dice:
"M5 3h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2Z M7 7h.01 M17 7h.01 M12 12h.01 M7 17h.01 M17 17h.01",
download: "M3 3h18v13H3V3Z M8 21h8 M12 16v5 M12 6v7 M9 10l3 3 3-3",
education: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9",
grid: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
growth: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
home: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8",
medical: "M9 3h6v6h6v6h-6v6H9v-6H3V9h6V3Z",
music: "M9 5v14a3 3 0 1 1-3-3h3 M9 5l11-2v14a3 3 0 1 1-3-3h3 M9 9l11-2",
refund: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14",
repayment: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
server:
"M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
shield: "M12 2l8 4v6c0 5-8 10-8 10S4 17 4 12V6l8-4Z M8 12l3 3 5-6",
shopping: "M5 7h14l2 14H3L5 7Z M8 8V6a4 4 0 0 1 8 0v2",
smoking:
"M3 14h18v5H3v-5Z M16 14v5 M8 10V8c0-2 3-2 3-4V2 M15 10V8c0-2 3-2 3-4",
subscriptions:
"M4 10a8 8 0 0 1 13-6l3 3 M20 3v4h-4 M20 14a8 8 0 0 1-13 6l-3-3 M4 21v-4h4",
taxi:
"M4 10l2-6h12l2 6 M3 10h18v8H3v-8Z M6 18v3 M18 18v3 M6 14h2 M16 14h2 M10 4V2h4v2",
topup:
"M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5",
transfer: "M3 7h18l-4-4 M21 17H3l4 4",
travel: "M10 3h4v7l7 5v3l-7-3v5l2 1v1l-4-1-4 1v-1l2-1v-5l-7 3v-3l7-5V3Z",
wifi:
"M3 7a14 14 0 0 1 18 0 M6 11a9 9 0 0 1 12 0 M9 15a4 4 0 0 1 6 0 M12 19h.01",
wine:
"M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8",
} as const;
type OperationIconProps = {
animation?: string;
color: string;
variant: keyof typeof paths;
};
const OperationIcon: FC<OperationIconProps> = (props) => {
const { animation, color, variant } = props;
// eslint-disable-next-line security/detect-object-injection -- Animation keys select CSS module classes only.
const animationClass = animation ? classes[animation] : "";
// eslint-disable-next-line security/detect-object-injection -- Variant is a closed keyof the package geometry map.
const parts = paths[variant]
.split(" M")
.map((part, index) => ({ d: index ? `M${part}` : part, key: part }));
return (
<svg
aria-hidden="true"
className={`${classes.icon} ${animationClass}`}
fill="none"
focusable="false"
height="24"
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.6"
style={{ color: `var(--mantine-color-${color}-7)`, flexShrink: 0 }}
viewBox="0 0 24 24"
width="24"
>
{parts.map(part => (
<path d={part.d} key={part.key} pathLength={1} />
))}
</svg>
);
};
export { OperationIcon, type OperationIconProps };
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

@@ -0,0 +1,91 @@
import { fireEvent, render, screen, 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(globalThis, "matchMedia").mockReturnValue({
matches: true,
} as MediaQueryList);
const { container } = render(
<button type="button">
<svg aria-hidden="true" ref={bindIconAnimation} />
Category
</button>,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon");
const target = screen.getByRole("button");
let finishFirst!: () => void;
let finishLast!: () => void;
const getAnimations = vi.fn(() => {
const first = Promise.withResolvers<void>();
const last = Promise.withResolvers<void>();
finishFirst = first.resolve;
finishLast = last.resolve;
return [{ finished: first.promise }, { finished: last.promise }];
});
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, unmount } = render(
<button type="button">
<svg aria-hidden="true" ref={bindIconAnimation} />
Category
</button>,
);
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- Bind browser animation doubles to the decorative SVG, which has no accessible role.
const icon = container.querySelector("svg");
if (!icon) throw new Error("Missing icon");
const target = screen.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");
});
+75
View File
@@ -0,0 +1,75 @@
// React ref cleanup keeps native animation lifetimes tied to the mounted icon.
const bindIconAnimation = (icon: null | SVGSVGElement) => {
if (!icon) return;
const target = icon.closest<HTMLElement>("a, button") ?? icon.parentElement;
if (!target) return;
let isHovered = false;
let isFocused = false;
let isRunning = false;
let isDisposed = false;
const release = () => {
if (!isRunning && !isHovered && !isFocused) {
delete icon.dataset.iconAnimating;
}
};
const start = () => {
if (Object.hasOwn(icon.dataset, "iconAnimating")) return;
icon.dataset.iconAnimating = "";
isRunning = 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 (isDisposed) return;
isRunning = false;
release();
});
};
const enter = () => {
if (!matchMedia("(hover: hover)").matches) return;
isHovered = true;
start();
};
const leave = () => {
isHovered = false;
release();
};
const focus = () => {
isFocused
= target.matches(":focus-visible")
|| !!target.querySelector(":focus-visible");
if (isFocused) start();
};
const blur = (event: FocusEvent) => {
if (
event.relatedTarget instanceof Node
&& target.contains(event.relatedTarget)
)
return;
isFocused = false;
release();
};
target.addEventListener("mouseenter", enter);
target.addEventListener("mouseleave", leave);
target.addEventListener("focusin", focus);
target.addEventListener("focusout", blur);
return () => {
isDisposed = true;
target.removeEventListener("mouseenter", enter);
target.removeEventListener("mouseleave", leave);
target.removeEventListener("focusin", focus);
target.removeEventListener("focusout", blur);
delete icon.dataset.iconAnimating;
};
};
export { bindIconAnimation };
+13
View File
@@ -1,4 +1,17 @@
export {
OperationIcon,
type OperationIconProps,
} from "./OperationIcon/OperationIcon";
export { AddIcon } from "./svg/AddIcon";
export { BondsIcon } from "./svg/BondsIcon";
export { CardsIcon } from "./svg/CardsIcon";
export { CategoriesIcon } from "./svg/CategoriesIcon";
export { ContactlessIcon } from "./svg/ContactlessIcon";
export { DeleteIcon } from "./svg/DeleteIcon";
export { EditIcon } from "./svg/EditIcon";
export { PortfolioIcon } from "./svg/PortfolioIcon";
export { RestoreIcon } from "./svg/RestoreIcon";
export { SettingsIcon } from "./svg/SettingsIcon";
export { StocksIcon } from "./svg/StocksIcon";
export { WalletIcon } from "./svg/WalletIcon";
export type { IconProps } from "./types";
+179
View File
@@ -0,0 +1,179 @@
.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;
}
}
.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,
.couponLines path {
stroke-dasharray: 1;
}
.topSlider {
--slider-offset: 3px;
--slider-delay: 0ms;
}
.bottomSlider {
--slider-offset: -3px;
--slider-delay: 160ms;
}
.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 450ms ease-out;
}
:is(.topSlider, .bottomSlider) {
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
}
}
}
@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: translateY(0);
}
20% {
transform: translateY(-3px);
}
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);
}
}
@keyframes stroke-draw {
0% {
stroke-dashoffset: 1;
}
100% {
stroke-dashoffset: 0;
}
}
@keyframes slider-slide {
0%,
100% {
transform: translateX(0);
}
50% {
transform: translateX(var(--slider-offset));
}
}
+1
View File
@@ -4,6 +4,7 @@ import type { IconProps } from "../types";
const AddIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const BondsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
<g className={classes.couponLines}>
<path d="M9 7h6" pathLength={1} />
<path d="M9 11h6" pathLength={1} />
<path d="M9 15h3" pathLength={1} />
</g>
</svg>
);
};
export { BondsIcon };
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const CardsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<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>
</svg>
);
};
export { CardsIcon };
+27
View File
@@ -0,0 +1,27 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const CategoriesIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h7v7H3zM14 3h7v7h-7zM3 14h7v7H3zM14 14h7v7h-7z" />
</svg>
);
};
export { CategoriesIcon };
@@ -0,0 +1,32 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const ContactlessIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path
d="M7 11a8 8 0 0 1 0 8m5-12a15 15 0 0 1 0 16m5-20a23 23 0 0 1 0 24"
stroke="currentColor"
strokeLinecap="round"
strokeWidth="2"
/>
</svg>
);
};
export { ContactlessIcon };
+1
View File
@@ -4,6 +4,7 @@ import type { IconProps } from "../types";
const DeleteIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
+1
View File
@@ -4,6 +4,7 @@ import type { IconProps } from "../types";
const EditIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
+43
View File
@@ -0,0 +1,43 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const PortfolioIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<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"
/>
</svg>
);
};
export { PortfolioIcon };
+27
View File
@@ -0,0 +1,27 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const RestoreIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3h18v4H3z M5 7v14h14V7 M12 17V10m-3 3 3-3 3 3" />
</svg>
);
};
export { RestoreIcon };
+35
View File
@@ -0,0 +1,35 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const SettingsIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M4 7h16M4 17h16" />
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
</svg>
);
};
export { SettingsIcon };
+39
View File
@@ -0,0 +1,39 @@
import type { FC } from "react";
import type { IconProps } from "../types";
import { bindIconAnimation } from "../bind-icon-animation";
import classes from "../navigation.module.css";
const StocksIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
ref={bindIconAnimation}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<path d="M3 3v18h18" />
<path
className={classes.drawStroke}
d="M7 14l5-5 4 3 5-7"
pathLength={1}
/>
<path className={classes.stockArrow} d="M16 5h5v5" />
</svg>
);
};
export { StocksIcon };
+29
View File
@@ -0,0 +1,29 @@
import type { FC } from "react";
import type { IconProps } from "../types";
const WalletIcon: FC<IconProps> = (props) => {
const { size = "24", ...svgProps } = props;
return (
<svg
aria-hidden="true"
fill="none"
focusable="false"
height={size}
stroke="currentColor"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth="1.5"
viewBox="0 0 24 24"
width={size}
{...svgProps}
>
<rect height="15" rx="3" width="18" x="3" y="5" />
<path d="M3 9h18M7 15h3M14 5V3H7a4 4 0 0 0-4 4" />
</svg>
);
};
export { WalletIcon };
+11 -3
View File
@@ -1,7 +1,10 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023", "DOM"],
"lib": [
"ES2023",
"DOM"
],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
@@ -11,7 +14,12 @@
"emitDeclarationOnly": true,
"rootDir": "src",
"outDir": "dist",
"jsx": "react-jsx"
"jsx": "react-jsx",
"types": [
"vite/client"
]
},
"include": ["src/index.ts"]
"include": [
"src/index.ts"
]
}