refactor: enforce frontend view model boundaries
This commit is contained in:
@@ -1,365 +0,0 @@
|
||||
.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,9 +1,17 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
|
||||
import { Stack } from "@mantine/core";
|
||||
import { OperationIcon } from "@wealthledger/icons";
|
||||
import { EntryCard } from "@wealthledger/ui";
|
||||
|
||||
import { BankOperationIcon } from "./BankOperationIcon";
|
||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||
|
||||
const BankOperationIcon = (props: {
|
||||
categoryName?: null | string;
|
||||
kind: "expense" | "refund" | "topup" | "transfer";
|
||||
}) => (
|
||||
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
|
||||
);
|
||||
|
||||
const meta = {
|
||||
args: { categoryName: "Продукты", kind: "expense" },
|
||||
@@ -11,8 +19,11 @@ const meta = {
|
||||
tags: ["autodocs"],
|
||||
title: "Product/BankCards/OperationIcons",
|
||||
} satisfies Meta<typeof BankOperationIcon>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Categories: Story = {
|
||||
render: () => (
|
||||
<Stack maw={640}>
|
||||
@@ -55,6 +66,7 @@ export const Categories: Story = {
|
||||
valueLabel="Расход"
|
||||
/>
|
||||
))}
|
||||
|
||||
<EntryCard
|
||||
color="green"
|
||||
icon={<BankOperationIcon kind="topup" />}
|
||||
|
||||
@@ -1,12 +1,21 @@
|
||||
import { render } from "@testing-library/react";
|
||||
import { OperationIcon } from "@wealthledger/icons";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { BankOperationIcon } from "./BankOperationIcon";
|
||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||
|
||||
const BankOperationIcon = (props: {
|
||||
categoryName?: null | string;
|
||||
kind: "expense" | "refund" | "topup" | "transfer";
|
||||
}) => (
|
||||
<OperationIcon {...bankOperationAppearance(props.kind, props.categoryName)} />
|
||||
);
|
||||
|
||||
it("matches normalized category names, falls back after renaming and distinguishes operation kinds", () => {
|
||||
const { container, rerender } = render(
|
||||
<BankOperationIcon categoryName="Жильё" kind="expense" />,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
const path = () => container.querySelector("path")?.getAttribute("d");
|
||||
const housing = path();
|
||||
@@ -15,16 +24,25 @@ it("matches normalized category names, falls back after renaming and distinguish
|
||||
rerender(<BankOperationIcon categoryName="Другое" kind="expense" />);
|
||||
const fallback = path();
|
||||
expect(fallback).not.toBe(housing);
|
||||
for (const categoryName of ["Моя категория", "constructor", /* eslint-disable-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract. */ null]) {
|
||||
|
||||
for (const categoryName of [
|
||||
"Моя категория",
|
||||
"constructor",
|
||||
// eslint-disable-next-line unicorn/no-null -- Exercise the explicit null returned by the production API/DOM contract.
|
||||
null,
|
||||
]) {
|
||||
rerender(<BankOperationIcon categoryName={categoryName} kind="expense" />);
|
||||
expect(path()).toBe(fallback);
|
||||
}
|
||||
|
||||
const paths = [fallback, housing];
|
||||
|
||||
for (const kind of ["topup", "transfer", "refund"] as const) {
|
||||
rerender(<BankOperationIcon categoryName="Жильё" kind={kind} />);
|
||||
expect(path()).toBeTruthy();
|
||||
paths.push(path());
|
||||
}
|
||||
|
||||
expect(new Set(paths).size).toBe(5);
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
expect(container.querySelector("svg")).toHaveAttribute("aria-hidden", "true");
|
||||
@@ -34,8 +52,10 @@ it("provides a distinct icon for every existing named category", () => {
|
||||
const { container, rerender } = render(
|
||||
<BankOperationIcon categoryName="Другое" kind="expense" />,
|
||||
);
|
||||
|
||||
// eslint-disable-next-line testing-library/no-container, testing-library/no-node-access -- This assertion checks DOM structure, decoration or focus behavior that semantic queries cannot establish.
|
||||
const paths = new Set([container.querySelector("path")?.getAttribute("d")]);
|
||||
|
||||
for (const categoryName of [
|
||||
"Алкоголь",
|
||||
"Вклады",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import type { FC } from "react";
|
||||
|
||||
import { bindIconAnimation } from "@/shared/lib";
|
||||
|
||||
import type { BankOperation } from "../../api/queries";
|
||||
|
||||
import { bankOperationAppearance } from "../../model/operation-appearance";
|
||||
import classes from "./BankOperationIcon.module.css";
|
||||
|
||||
type BankOperationIconProps = {
|
||||
categoryName?: null | string;
|
||||
kind: BankOperation["kind"];
|
||||
};
|
||||
const BankOperationIcon: FC<BankOperationIconProps> = (props) => {
|
||||
const icon = bankOperationAppearance(props.kind, props.categoryName);
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={`${classes.icon} ${icon.animation ? classes[icon.animation] : ""}`}
|
||||
fill="none"
|
||||
focusable="false"
|
||||
height="24"
|
||||
ref={bindIconAnimation}
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="1.6"
|
||||
style={{ color: `var(--mantine-color-${icon.color}-7)`, flexShrink: 0 }}
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
>
|
||||
{icon.path.split(" M").map((part, index) => (
|
||||
<path d={index ? `M${part}` : part} key={part} pathLength={1} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { BankOperationIcon };
|
||||
Reference in New Issue
Block a user