feat(ui): animate navigation and operation icons
This commit is contained in:
@@ -1,17 +1,20 @@
|
|||||||
import type { BankOperation } from "../api/queries";
|
import type { BankOperation } from "../api/queries";
|
||||||
|
|
||||||
type OperationIcon = { path: string; color: string };
|
type OperationIcon = { path: string; color: string; animation?: string };
|
||||||
|
|
||||||
const categoryIcons: Record<string, OperationIcon> = {
|
const categoryIcons: Record<string, OperationIcon> = {
|
||||||
алкоголь: {
|
алкоголь: {
|
||||||
path: "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10 M12 13v8 M8 21h8",
|
animation: "wine",
|
||||||
|
path: "M7 3h10v5a5 5 0 0 1-10 0V3Z M7 7h10v1a5 5 0 0 1-10 0V7Z M12 13v8 M8 21h8",
|
||||||
color: "grape",
|
color: "grape",
|
||||||
},
|
},
|
||||||
вклады: {
|
вклады: {
|
||||||
path: "M4 8c0-4 16-4 16 0s-16 4-16 0Z M4 8v5c0 4 16 4 16 0V8 M4 13v5c0 4 16 4 16 0v-5",
|
animation: "deposit",
|
||||||
|
path: "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",
|
||||||
color: "green",
|
color: "green",
|
||||||
},
|
},
|
||||||
"инвестиции и накопления": {
|
"инвестиции и накопления": {
|
||||||
|
animation: "growth",
|
||||||
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
|
path: "M3 3v18h18 M6 15l5-5 4 3 6-8 M16 5h5v5",
|
||||||
color: "green",
|
color: "green",
|
||||||
},
|
},
|
||||||
@@ -19,9 +22,14 @@ const categoryIcons: Record<string, OperationIcon> = {
|
|||||||
path: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9",
|
path: "M2 8l10-5 10 5-10 5L2 8Z M6 10v7c4 3 8 3 12 0v-7 M22 8v9",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
},
|
},
|
||||||
переводы: { path: "M3 7h18l-4-4 M21 17H3l4 4", color: "blue" },
|
переводы: {
|
||||||
|
animation: "transfer",
|
||||||
|
path: "M3 7h18l-4-4 M21 17H3l4 4",
|
||||||
|
color: "blue",
|
||||||
|
},
|
||||||
"погашение кредита": {
|
"погашение кредита": {
|
||||||
path: "M3 5h18v14H3V5Z M3 9h18 M8 14l3 3 5-5",
|
animation: "repayment",
|
||||||
|
path: "M3 5h18v14H3V5Z M3 9h18 M10 18v-7h3a2 2 0 0 1 0 4H9m0 1.5h4",
|
||||||
color: "orange",
|
color: "orange",
|
||||||
},
|
},
|
||||||
подписки: {
|
подписки: {
|
||||||
@@ -33,6 +41,7 @@ const categoryIcons: Record<string, OperationIcon> = {
|
|||||||
color: "blue",
|
color: "blue",
|
||||||
},
|
},
|
||||||
"связь и интернет": {
|
"связь и интернет": {
|
||||||
|
animation: "wifi",
|
||||||
path: "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",
|
path: "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",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
},
|
},
|
||||||
@@ -53,6 +62,7 @@ const categoryIcons: Record<string, OperationIcon> = {
|
|||||||
color: "orange",
|
color: "orange",
|
||||||
},
|
},
|
||||||
"хостинг и ии-сервисы": {
|
"хостинг и ии-сервисы": {
|
||||||
|
animation: "server",
|
||||||
path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
|
path: "M3 3h18v7H3V3Z M3 14h18v7H3v-7Z M7 6.5h.01 M7 17.5h.01 M15 6.5h3 M15 17.5h3",
|
||||||
color: "grape",
|
color: "grape",
|
||||||
},
|
},
|
||||||
@@ -61,11 +71,13 @@ const categoryIcons: Record<string, OperationIcon> = {
|
|||||||
color: "blue",
|
color: "blue",
|
||||||
},
|
},
|
||||||
продукты: {
|
продукты: {
|
||||||
path: "M3 9h18l-2 11H5L3 9Z M8 9l4-6 4 6 M9 13v3 M15 13v3",
|
animation: "basket",
|
||||||
|
path: "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",
|
||||||
color: "green",
|
color: "green",
|
||||||
},
|
},
|
||||||
кафе: {
|
кафе: {
|
||||||
path: "M4 8h12v7a4 4 0 0 1-4 4H8a4 4 0 0 1-4-4V8Z M16 9h2a3 3 0 0 1 0 6h-2 M8 3v2 M12 3v2 M3 22h15",
|
animation: "coffee",
|
||||||
|
path: "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",
|
||||||
color: "orange",
|
color: "orange",
|
||||||
},
|
},
|
||||||
такси: {
|
такси: {
|
||||||
@@ -73,7 +85,8 @@ const categoryIcons: Record<string, OperationIcon> = {
|
|||||||
color: "yellow",
|
color: "yellow",
|
||||||
},
|
},
|
||||||
транспорт: {
|
транспорт: {
|
||||||
path: "M6 3h12a2 2 0 0 1 2 2v13H4V5a2 2 0 0 1 2-2Z M4 11h16 M7 15h1 M16 15h1 M7 18v3 M17 18v3 M9 6h6",
|
animation: "car",
|
||||||
|
path: "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",
|
||||||
color: "blue",
|
color: "blue",
|
||||||
},
|
},
|
||||||
жилье: { path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8", color: "orange" },
|
жилье: { path: "M3 11l9-8 9 8 M5 10v11h14V10 M9 21v-8h6v8", color: "orange" },
|
||||||
@@ -87,16 +100,25 @@ const categoryIcons: Record<string, OperationIcon> = {
|
|||||||
color: "grape",
|
color: "grape",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const otherIcon = {
|
const otherIcon: OperationIcon = {
|
||||||
|
animation: "grid",
|
||||||
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
|
path: "M4 4h6v6H4V4Z M14 4h6v6h-6V4Z M4 14h6v6H4v-6Z M14 14h6v6h-6v-6Z",
|
||||||
color: "gray",
|
color: "gray",
|
||||||
};
|
};
|
||||||
const operationIcons = {
|
const operationIcons: Record<
|
||||||
|
Exclude<BankOperation["kind"], "expense">,
|
||||||
|
OperationIcon
|
||||||
|
> = {
|
||||||
topup: {
|
topup: {
|
||||||
path: "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5 M13.5 14.5h5",
|
animation: "topup",
|
||||||
|
path: "M20 8V5H5a2 2 0 0 1 0-4h13v4 M3 3v16a2 2 0 0 0 2 2h15V8H5 M16 12v5m-2.5-2.5h5",
|
||||||
color: "green",
|
color: "green",
|
||||||
},
|
},
|
||||||
transfer: { path: "M3 7h18l-4-4 M21 17H3l4 4", color: "blue" },
|
transfer: {
|
||||||
|
animation: "transfer",
|
||||||
|
path: "M3 7h18l-4-4 M21 17H3l4 4",
|
||||||
|
color: "blue",
|
||||||
|
},
|
||||||
refund: { path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14", color: "teal" },
|
refund: { path: "M8 4L3 9l5 5 M3 9h11a7 7 0 0 1 0 14", color: "teal" },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import type { FC } from "react";
|
import type { FC } from "react";
|
||||||
|
import { bindIconAnimation } from "@/shared/lib";
|
||||||
import type { BankOperation } from "../api/queries";
|
import type { BankOperation } from "../api/queries";
|
||||||
|
|
||||||
import { bankOperationAppearance } from "../model/operation-appearance";
|
import { bankOperationAppearance } from "../model/operation-appearance";
|
||||||
|
import classes from "./BankOperationIcon.module.css";
|
||||||
|
|
||||||
type BankOperationIconProps = {
|
type BankOperationIconProps = {
|
||||||
kind: BankOperation["kind"];
|
kind: BankOperation["kind"];
|
||||||
@@ -11,6 +13,8 @@ const BankOperationIcon: FC<BankOperationIconProps> = (props) => {
|
|||||||
const icon = bankOperationAppearance(props.kind, props.categoryName);
|
const icon = bankOperationAppearance(props.kind, props.categoryName);
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
|
ref={bindIconAnimation}
|
||||||
|
className={`${classes.icon} ${icon.animation ? classes[icon.animation] : ""}`}
|
||||||
width="24"
|
width="24"
|
||||||
height="24"
|
height="24"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
@@ -23,7 +27,9 @@ const BankOperationIcon: FC<BankOperationIconProps> = (props) => {
|
|||||||
focusable="false"
|
focusable="false"
|
||||||
style={{ flexShrink: 0, color: `var(--mantine-color-${icon.color}-7)` }}
|
style={{ flexShrink: 0, color: `var(--mantine-color-${icon.color}-7)` }}
|
||||||
>
|
>
|
||||||
<path d={icon.path} />
|
{icon.path.split(" M").map((part, index) => (
|
||||||
|
<path key={part} d={index ? `M${part}` : part} pathLength={1} />
|
||||||
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 {
|
export {
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
|
|||||||
@@ -18,56 +18,192 @@
|
|||||||
flex-shrink: 0;
|
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) {
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
.drawStroke {
|
.navLink {
|
||||||
|
transition: background-color 180ms ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawStroke,
|
||||||
|
.couponLines path {
|
||||||
stroke-dasharray: 1;
|
stroke-dasharray: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topSlider {
|
.topSlider {
|
||||||
--slider-offset: -3px;
|
--slider-offset: 3px;
|
||||||
|
--slider-delay: 0ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bottomSlider {
|
.bottomSlider {
|
||||||
--slider-offset: 3px;
|
--slider-offset: -3px;
|
||||||
|
--slider-delay: 160ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navLink:focus-visible {
|
.couponLines path:nth-child(2) {
|
||||||
.portfolioIcon {
|
--line-delay: 140ms;
|
||||||
animation: portfolio-sway 650ms ease-in-out;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.drawStroke {
|
||||||
animation: stroke-draw 650ms ease-in-out;
|
animation: stroke-draw 450ms ease-out;
|
||||||
}
|
}
|
||||||
:is(.topSlider, .bottomSlider) {
|
:is(.topSlider, .bottomSlider) {
|
||||||
animation: slider-slide 650ms ease-in-out;
|
animation: slider-slide 650ms ease-in-out var(--slider-delay) both;
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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%,
|
0%,
|
||||||
100% {
|
100% {
|
||||||
transform: rotate(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
30% {
|
20% {
|
||||||
transform: translateY(-1px) rotate(-10deg);
|
transform: translateY(-3px);
|
||||||
}
|
}
|
||||||
65% {
|
40%,
|
||||||
transform: rotate(7deg);
|
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 type { FC, ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
|
import { Outlet, NavLink as RouterNavLink, useLocation } from "react-router";
|
||||||
|
import { bindIconAnimation } from "../lib";
|
||||||
import classes from "./AppShell.module.css";
|
import classes from "./AppShell.module.css";
|
||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
@@ -18,52 +19,75 @@ const AppShell: FC<AppShellProps> = (props) => {
|
|||||||
label: t("navigation.accounts"),
|
label: t("navigation.accounts"),
|
||||||
iconClassName: classes.portfolioIcon,
|
iconClassName: classes.portfolioIcon,
|
||||||
icon: (
|
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",
|
to: "/cards",
|
||||||
label: t("navigation.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",
|
to: "/stocks",
|
||||||
label: t("navigation.stocks"),
|
label: t("navigation.stocks"),
|
||||||
|
iconClassName: classes.stocksIcon,
|
||||||
icon: (
|
icon: (
|
||||||
<>
|
<>
|
||||||
<path d="M3 3v18h18" />
|
<path d="M3 3v18h18" />
|
||||||
<path
|
<path
|
||||||
className={classes.drawStroke}
|
className={classes.drawStroke}
|
||||||
pathLength={1}
|
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",
|
to: "/bonds",
|
||||||
label: t("navigation.bonds"),
|
label: t("navigation.bonds"),
|
||||||
|
iconClassName: classes.bondsIcon,
|
||||||
icon: (
|
icon: (
|
||||||
<>
|
<>
|
||||||
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
|
<path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Z" />
|
||||||
<path
|
<g className={classes.couponLines}>
|
||||||
className={classes.drawStroke}
|
<path pathLength={1} d="M9 7h6" />
|
||||||
pathLength={1}
|
<path pathLength={1} d="M9 11h6" />
|
||||||
d="M9 7h6M9 11h6M9 15h3"
|
<path pathLength={1} d="M9 15h3" />
|
||||||
/>
|
</g>
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
to: "/settings",
|
to: "/settings",
|
||||||
label: t("navigation.settings"),
|
label: t("navigation.settings"),
|
||||||
|
iconClassName: classes.settingsIcon,
|
||||||
icon: (
|
icon: (
|
||||||
<>
|
<>
|
||||||
<path className={classes.topSlider} d="M4 7h9m4 0h3M13 4v6h4V4h-4Z" />
|
<path d="M4 7h16M4 17h16" />
|
||||||
<path
|
<path className={classes.topSlider} d="M10 4v6h4V4h-4Z" />
|
||||||
className={classes.bottomSlider}
|
<path className={classes.bottomSlider} d="M10 14v6h4v-6h-4Z" />
|
||||||
d="M4 17h3m4 0h9M7 14v6h4v-6H7Z"
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
@@ -91,6 +115,7 @@ const AppShell: FC<AppShellProps> = (props) => {
|
|||||||
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
|
className={`${classes.navIcon} ${item.iconClassName ?? ""}`}
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
|
ref={bindIconAnimation}
|
||||||
width={20}
|
width={20}
|
||||||
height={20}
|
height={20}
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
|
|||||||
Reference in New Issue
Block a user