refactor: establish local frontend workspace boundaries
Conventional commits / commits (pull_request) Successful in 24s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m18s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s
Conventional commits / commits (pull_request) Successful in 24s
Frontend security / security (pull_request) Successful in 9s
Frontend verify / verify (pull_request) Successful in 5m18s
Frontend security / security (push) Successful in 10s
Frontend verify / verify (push) Successful in 6m45s
OpenSpec change: evolve-frontend-stack
This commit was merged in pull request #34.
This commit is contained in:
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "@wealthledger/icons",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"sideEffects": false,
|
||||
"scripts": {
|
||||
"build": "vite build && tsc -p tsconfig.json"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/react-vite": "10.6.0",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/react": "19.3.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { AddIcon, DeleteIcon, EditIcon } from "./index";
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Icons",
|
||||
component: AddIcon,
|
||||
tags: ["autodocs"],
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Статичные SVG: size по умолчанию 24, strokeWidth 1.5, цвет currentColor. В кнопке иконка декоративная, имя принадлежит действию. Для самостоятельного изображения задайте aria-hidden={false}, role=img и aria-label либо aria-labelledby. Геометрия взята из Card/Cards; подписи и действия остаются у приложения.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof AddIcon>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Decorative: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 16, color: "#2563eb" }}>
|
||||
<button type="button" aria-label="Добавить пример">
|
||||
<AddIcon size={20} strokeWidth={2} />
|
||||
</button>
|
||||
<button type="button" aria-label="Изменить пример">
|
||||
<EditIcon size={18} />
|
||||
</button>
|
||||
<button type="button" aria-label="Удалить пример">
|
||||
<DeleteIcon size={18} />
|
||||
</button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const Meaningful: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: "flex", gap: 16, color: "#2563eb" }}>
|
||||
<AddIcon aria-hidden={false} role="img" aria-label="Добавление" />
|
||||
<EditIcon aria-hidden={false} role="img" aria-label="Редактирование" />
|
||||
<DeleteIcon aria-hidden={false} role="img" aria-label="Удаление" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { AddIcon, DeleteIcon, EditIcon } from "./index";
|
||||
|
||||
describe("icon accessibility", () => {
|
||||
it.each([AddIcon, EditIcon, DeleteIcon])(
|
||||
"keeps decoration hidden and exposes explicitly named images: %s",
|
||||
(Icon) => {
|
||||
const { rerender } = render(
|
||||
<button type="button" aria-label="Действие">
|
||||
<Icon />
|
||||
</button>,
|
||||
);
|
||||
expect(screen.queryByRole("img")).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "Действие" }).querySelector("svg"),
|
||||
).toHaveAttribute("focusable", "false");
|
||||
rerender(
|
||||
<Icon
|
||||
aria-hidden={false}
|
||||
role="img"
|
||||
aria-label="Пример"
|
||||
size={18}
|
||||
strokeWidth={2}
|
||||
/>,
|
||||
);
|
||||
const icon = screen.getByRole("img", { name: "Пример" });
|
||||
expect(icon).toHaveAttribute("width", "18");
|
||||
expect(icon).toHaveAttribute("height", "18");
|
||||
expect(icon).toHaveAttribute("stroke-width", "2");
|
||||
expect(icon).toHaveAttribute("stroke", "currentColor");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
export { AddIcon } from "./svg/AddIcon";
|
||||
export { DeleteIcon } from "./svg/DeleteIcon";
|
||||
export { EditIcon } from "./svg/EditIcon";
|
||||
export type { IconProps } from "./types";
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { FC } from "react";
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
const AddIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M12 5v14 M5 12h14" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { AddIcon };
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FC } from "react";
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
const DeleteIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="M3 6h18 M9 6V3h6v3 M5 6l1 15h12l1-15 M10 10v7 M14 10v7" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { DeleteIcon };
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FC } from "react";
|
||||
import type { IconProps } from "../types";
|
||||
|
||||
const EditIcon: FC<IconProps> = (props) => {
|
||||
const { size = "24", ...svgProps } = props;
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
focusable="false"
|
||||
{...svgProps}
|
||||
>
|
||||
<path d="m16 3 5 5-12 12-6 1 1-6L16 3Z M13 6l5 5" />
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export { EditIcon };
|
||||
@@ -0,0 +1,10 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
|
||||
type IconProps = Omit<
|
||||
ComponentPropsWithoutRef<"svg">,
|
||||
"children" | "dangerouslySetInnerHTML"
|
||||
> & {
|
||||
size?: number | string;
|
||||
};
|
||||
|
||||
export type { IconProps };
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src/index.ts"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: { entry: "src/index.ts", formats: ["es"], fileName: "index" },
|
||||
rolldownOptions: { external: /^react(\/|$)/ },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@wealthledger/tokens",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"tokens.json"
|
||||
],
|
||||
"exports": {
|
||||
"./tokens.json": "./tokens.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
{
|
||||
"formatVersion": 1,
|
||||
"description": "Переносимые токены WealthLedger. Размеры px адаптируются к масштабу runtime; breakpoints em не масштабируются. Светлая схема; роли с shade null используют primaryShade.light для палитры или base color без оттенка.",
|
||||
"color": {
|
||||
"base": {
|
||||
"white": "#fff",
|
||||
"black": "#000"
|
||||
},
|
||||
"palettes": {
|
||||
"blue": [
|
||||
"#eff6ff",
|
||||
"#dbeafe",
|
||||
"#bfdbfe",
|
||||
"#93c5fd",
|
||||
"#60a5fa",
|
||||
"#3b82f6",
|
||||
"#2563eb",
|
||||
"#1d4ed8",
|
||||
"#1e40af",
|
||||
"#1e3a8a"
|
||||
],
|
||||
"gray": [
|
||||
"#f8f9fa",
|
||||
"#f1f3f5",
|
||||
"#e9ecef",
|
||||
"#dee2e6",
|
||||
"#ced4da",
|
||||
"#adb5bd",
|
||||
"#868e96",
|
||||
"#495057",
|
||||
"#343a40",
|
||||
"#212529"
|
||||
],
|
||||
"dark": [
|
||||
"#C9C9C9",
|
||||
"#b8b8b8",
|
||||
"#828282",
|
||||
"#696969",
|
||||
"#424242",
|
||||
"#3b3b3b",
|
||||
"#2e2e2e",
|
||||
"#242424",
|
||||
"#1f1f1f",
|
||||
"#141414"
|
||||
],
|
||||
"green": [
|
||||
"#ebfbee",
|
||||
"#d3f9d8",
|
||||
"#b2f2bb",
|
||||
"#8ce99a",
|
||||
"#69db7c",
|
||||
"#51cf66",
|
||||
"#40c057",
|
||||
"#37b24d",
|
||||
"#2f9e44",
|
||||
"#237032"
|
||||
],
|
||||
"red": [
|
||||
"#fff5f5",
|
||||
"#ffe3e3",
|
||||
"#ffc9c9",
|
||||
"#ffa8a8",
|
||||
"#ff8787",
|
||||
"#ff6b6b",
|
||||
"#fa5252",
|
||||
"#f03e3e",
|
||||
"#e03131",
|
||||
"#c92a2a"
|
||||
],
|
||||
"yellow": [
|
||||
"#fff9db",
|
||||
"#fff3bf",
|
||||
"#ffec99",
|
||||
"#ffe066",
|
||||
"#ffd43b",
|
||||
"#fcc419",
|
||||
"#fab005",
|
||||
"#f59f00",
|
||||
"#f08c00",
|
||||
"#e67700"
|
||||
],
|
||||
"grape": [
|
||||
"#f8f0fc",
|
||||
"#f3d9fa",
|
||||
"#eebefa",
|
||||
"#e599f7",
|
||||
"#da77f2",
|
||||
"#cc5de8",
|
||||
"#be4bdb",
|
||||
"#ae3ec9",
|
||||
"#9c36b5",
|
||||
"#862e9c"
|
||||
],
|
||||
"orange": [
|
||||
"#fff4e6",
|
||||
"#ffe8cc",
|
||||
"#ffd8a8",
|
||||
"#ffc078",
|
||||
"#ffa94d",
|
||||
"#ff922b",
|
||||
"#fd7e14",
|
||||
"#f76707",
|
||||
"#e8590c",
|
||||
"#d9480f"
|
||||
]
|
||||
},
|
||||
"primaryShade": {
|
||||
"light": 6,
|
||||
"dark": 8
|
||||
},
|
||||
"roles": {
|
||||
"canvas": {
|
||||
"color": "gray",
|
||||
"shade": 2,
|
||||
"description": "Фон приложения"
|
||||
},
|
||||
"surface": {
|
||||
"color": "white",
|
||||
"shade": null,
|
||||
"description": "Поверхность Paper / формы"
|
||||
},
|
||||
"subtle": {
|
||||
"color": "gray",
|
||||
"shade": 0,
|
||||
"description": "Приглушённая поверхность сводки"
|
||||
},
|
||||
"text": {
|
||||
"color": "dark",
|
||||
"shade": 7,
|
||||
"description": "Основной текст страницы"
|
||||
},
|
||||
"muted": {
|
||||
"color": "gray",
|
||||
"shade": 6,
|
||||
"description": "Вторичный текст; проверяйте контраст при выборе фона"
|
||||
},
|
||||
"border": {
|
||||
"color": "gray",
|
||||
"shade": 3,
|
||||
"description": "Граница поверхности"
|
||||
},
|
||||
"primary": {
|
||||
"color": "blue",
|
||||
"shade": null,
|
||||
"description": "Основное действие и активная навигация"
|
||||
},
|
||||
"info": {
|
||||
"color": "blue",
|
||||
"shade": 6,
|
||||
"description": "Информационное сообщение"
|
||||
},
|
||||
"positive": {
|
||||
"color": "green",
|
||||
"shade": 9,
|
||||
"description": "Положительный результат; дополните знаком и текстом"
|
||||
},
|
||||
"negative": {
|
||||
"color": "red",
|
||||
"shade": 9,
|
||||
"description": "Ошибка / отрицательный результат; дополните текстом"
|
||||
},
|
||||
"warning": {
|
||||
"color": "yellow",
|
||||
"shade": 6,
|
||||
"description": "Предупреждение; не мелкий текст на белом фоне"
|
||||
}
|
||||
}
|
||||
},
|
||||
"typography": {
|
||||
"fontFamily": "-apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif",
|
||||
"fontFamilyMonospace": "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace",
|
||||
"unit": "px",
|
||||
"fontSizes": {
|
||||
"xs": 12.0,
|
||||
"sm": 14.0,
|
||||
"md": 16.0,
|
||||
"lg": 18.0,
|
||||
"xl": 20.0
|
||||
},
|
||||
"fontWeights": {
|
||||
"regular": 400,
|
||||
"medium": 600,
|
||||
"bold": 700
|
||||
},
|
||||
"lineHeights": {
|
||||
"xs": 1.4,
|
||||
"sm": 1.45,
|
||||
"md": 1.55,
|
||||
"lg": 1.6,
|
||||
"xl": 1.65
|
||||
},
|
||||
"headings": {
|
||||
"h1": {
|
||||
"fontSize": 34.0,
|
||||
"lineHeight": 1.3
|
||||
},
|
||||
"h2": {
|
||||
"fontSize": 26.0,
|
||||
"lineHeight": 1.35
|
||||
},
|
||||
"h3": {
|
||||
"fontSize": 22.0,
|
||||
"lineHeight": 1.4
|
||||
},
|
||||
"h4": {
|
||||
"fontSize": 18.0,
|
||||
"lineHeight": 1.45
|
||||
},
|
||||
"h5": {
|
||||
"fontSize": 16.0,
|
||||
"lineHeight": 1.5
|
||||
},
|
||||
"h6": {
|
||||
"fontSize": 14.0,
|
||||
"lineHeight": 1.5
|
||||
}
|
||||
}
|
||||
},
|
||||
"spacing": {
|
||||
"unit": "px",
|
||||
"values": {
|
||||
"xs": 10.0,
|
||||
"sm": 12.0,
|
||||
"md": 16.0,
|
||||
"lg": 20.0,
|
||||
"xl": 32.0
|
||||
}
|
||||
},
|
||||
"radius": {
|
||||
"unit": "px",
|
||||
"default": "md",
|
||||
"values": {
|
||||
"xs": 2.0,
|
||||
"sm": 4.0,
|
||||
"md": 8.0,
|
||||
"lg": 16.0,
|
||||
"xl": 32.0
|
||||
}
|
||||
},
|
||||
"shadows": {
|
||||
"unit": "px",
|
||||
"values": {
|
||||
"xs": [
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 1.0,
|
||||
"blur": 3.0,
|
||||
"spread": 0,
|
||||
"color": "black",
|
||||
"opacity": 0.05
|
||||
},
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 1.0,
|
||||
"blur": 2.0,
|
||||
"spread": 0,
|
||||
"color": "black",
|
||||
"opacity": 0.1
|
||||
}
|
||||
],
|
||||
"sm": [
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 1.0,
|
||||
"blur": 3.0,
|
||||
"spread": 0,
|
||||
"color": "black",
|
||||
"opacity": 0.05
|
||||
},
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 10.0,
|
||||
"blur": 15.0,
|
||||
"spread": -5.0,
|
||||
"color": "black",
|
||||
"opacity": 0.05
|
||||
},
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 7.0,
|
||||
"blur": 7.0,
|
||||
"spread": -5.0,
|
||||
"color": "black",
|
||||
"opacity": 0.04
|
||||
}
|
||||
],
|
||||
"md": [
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 1.0,
|
||||
"blur": 3.0,
|
||||
"spread": 0,
|
||||
"color": "black",
|
||||
"opacity": 0.05
|
||||
},
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 20.0,
|
||||
"blur": 25.0,
|
||||
"spread": -5.0,
|
||||
"color": "black",
|
||||
"opacity": 0.05
|
||||
},
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 10.0,
|
||||
"blur": 10.0,
|
||||
"spread": -5.0,
|
||||
"color": "black",
|
||||
"opacity": 0.04
|
||||
}
|
||||
],
|
||||
"lg": [
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 1.0,
|
||||
"blur": 3.0,
|
||||
"spread": 0,
|
||||
"color": "black",
|
||||
"opacity": 0.05
|
||||
},
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 28.0,
|
||||
"blur": 23.0,
|
||||
"spread": -7.0,
|
||||
"color": "black",
|
||||
"opacity": 0.05
|
||||
},
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 12.0,
|
||||
"blur": 12.0,
|
||||
"spread": -7.0,
|
||||
"color": "black",
|
||||
"opacity": 0.04
|
||||
}
|
||||
],
|
||||
"xl": [
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 1.0,
|
||||
"blur": 3.0,
|
||||
"spread": 0,
|
||||
"color": "black",
|
||||
"opacity": 0.05
|
||||
},
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 36.0,
|
||||
"blur": 28.0,
|
||||
"spread": -7.0,
|
||||
"color": "black",
|
||||
"opacity": 0.05
|
||||
},
|
||||
{
|
||||
"offsetX": 0.0,
|
||||
"offsetY": 17.0,
|
||||
"blur": 17.0,
|
||||
"spread": -7.0,
|
||||
"color": "black",
|
||||
"opacity": 0.04
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"breakpoints": {
|
||||
"unit": "em",
|
||||
"values": {
|
||||
"xs": 36.0,
|
||||
"sm": 48.0,
|
||||
"md": 62.0,
|
||||
"lg": 75.0,
|
||||
"xl": 88.0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "@wealthledger/ui",
|
||||
"version": "0.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./theme": {
|
||||
"types": "./dist/theme.d.ts",
|
||||
"import": "./dist/theme.js"
|
||||
},
|
||||
"./styles.css": "./dist/styles.css"
|
||||
},
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "vite build && tsc -p tsconfig.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@wealthledger/tokens": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@mantine/core": "9.6.1",
|
||||
"@mantine/hooks": "9.6.1",
|
||||
"react": "19.3.0",
|
||||
"react-dom": "19.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/react-vite": "10.6.0",
|
||||
"@testing-library/react": "16.3.3",
|
||||
"@types/react": "19.3.0",
|
||||
"storybook": "10.6.0",
|
||||
"typescript": "5.9.3",
|
||||
"vite": "8.3.0",
|
||||
"vitest": "5.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
.row {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: var(--mantine-spacing-sm);
|
||||
align-items: center;
|
||||
padding: var(--mantine-spacing-md);
|
||||
border-color: var(--wl-color-border);
|
||||
background: var(--wl-color-subtle);
|
||||
}
|
||||
|
||||
.identity,
|
||||
.value {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.positive {
|
||||
color: var(--wl-color-positive);
|
||||
}
|
||||
|
||||
.negative {
|
||||
color: var(--wl-color-negative);
|
||||
}
|
||||
|
||||
.neutral {
|
||||
color: var(--mantine-color-gray-7);
|
||||
}
|
||||
|
||||
@media (max-width: 36em) {
|
||||
.row {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.value {
|
||||
grid-column: 2;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.valueFooter {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
justify-items: end;
|
||||
min-height: 28px;
|
||||
}
|
||||
.valueLabel,
|
||||
.actions {
|
||||
grid-area: 1 / 1;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
.valueLabel {
|
||||
opacity: 0;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.valueLabel {
|
||||
opacity: 1;
|
||||
}
|
||||
.actions {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.row:hover .actions,
|
||||
.row:focus-within .actions {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.row:hover .valueLabel,
|
||||
.row:focus-within .valueLabel {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@media (max-width: 36em) {
|
||||
.valueFooter {
|
||||
justify-items: start;
|
||||
}
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.actions button {
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.valueLabel,
|
||||
.actions {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { EntryCard } from "./EntryCard";
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Components/EntryCard",
|
||||
component: EntryCard,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
title:
|
||||
"Учебная запись с очень длинным названием для проверки узкого экрана",
|
||||
metadata: "22.09.2026 · Синтетические данные",
|
||||
initial: "У",
|
||||
imageSrc: "data:image/png;base64,invalid",
|
||||
value: "−1 250,00 ₽",
|
||||
valueLabel: "Тестовое значение",
|
||||
tone: "negative",
|
||||
actions: (
|
||||
<Button size="compact-sm" onClick={fn()}>
|
||||
Открыть пример
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Готовые строки и slots без финансовых вычислений. Невалидное локальное изображение показывает fallback; действие доступно с клавиатуры и на узком экране. Данные вымышлены.",
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof EntryCard>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const LongContentAndFallback: Story = {};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { fireEvent, render, screen } from "@testing-library/react";
|
||||
import { assert, describe, expect, it } from "vitest";
|
||||
import { EntryCard } from "./EntryCard";
|
||||
|
||||
describe("EntryCard", () => {
|
||||
it("keeps the fallback after an image fails", () => {
|
||||
const props = {
|
||||
title: "Пример",
|
||||
metadata: "Описание",
|
||||
initial: "У",
|
||||
value: "—",
|
||||
valueLabel: "Неизвестно",
|
||||
tone: "neutral" as const,
|
||||
imageSrc: "data:image/png;base64,invalid",
|
||||
};
|
||||
const { container, rerender } = render(
|
||||
<MantineProvider>
|
||||
<EntryCard {...props} />
|
||||
</MantineProvider>,
|
||||
);
|
||||
// Decorative image has no accessible name; trigger its actual failure event.
|
||||
const image = container.querySelector("img");
|
||||
assert(image);
|
||||
fireEvent.error(image);
|
||||
rerender(
|
||||
<MantineProvider>
|
||||
<EntryCard {...props} title="Обновлённый пример" />
|
||||
</MantineProvider>,
|
||||
);
|
||||
expect(container.querySelector("img")).toBeNull();
|
||||
expect(screen.getByText("У")).toBeInTheDocument();
|
||||
expect(screen.getByText("Обновлённый пример")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Avatar, Paper, Stack, Text } from "@mantine/core";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import classes from "./EntryCard.module.css";
|
||||
|
||||
type EntryCardProps = {
|
||||
title: string;
|
||||
metadata: string;
|
||||
details?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
initial: string;
|
||||
icon?: ReactNode;
|
||||
color?: string;
|
||||
imageSrc?: string | null;
|
||||
value: string;
|
||||
valueLabel: string;
|
||||
tone: "positive" | "negative" | "neutral";
|
||||
};
|
||||
|
||||
const EntryCard: FC<EntryCardProps> = (props) => {
|
||||
const [imageFailed, setImageFailed] = useState(false);
|
||||
return (
|
||||
<Paper
|
||||
className={classes.row}
|
||||
withBorder
|
||||
style={
|
||||
props.color
|
||||
? {
|
||||
background: `color-mix(in srgb, var(--mantine-color-${props.color}-0) 45%, var(--wl-color-surface))`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
radius="md"
|
||||
size={40}
|
||||
src={imageFailed ? null : props.imageSrc}
|
||||
onError={() => setImageFailed(true)}
|
||||
>
|
||||
{props.icon ?? props.initial}
|
||||
</Avatar>
|
||||
<Stack className={classes.identity} gap={2}>
|
||||
<Text fw={700} lineClamp={1}>
|
||||
{props.title}
|
||||
</Text>
|
||||
<Text c="dimmed" size="sm">
|
||||
{props.metadata}
|
||||
</Text>
|
||||
{props.details}
|
||||
</Stack>
|
||||
<Stack className={classes.value} gap={2}>
|
||||
<Text className={classes[props.tone]} fw={700}>
|
||||
{props.value}
|
||||
</Text>
|
||||
<div className={props.actions ? classes.valueFooter : undefined}>
|
||||
<Text
|
||||
className={`${classes[props.tone]} ${props.actions ? classes.valueLabel : ""}`}
|
||||
size="sm"
|
||||
>
|
||||
{props.valueLabel}
|
||||
</Text>
|
||||
{props.actions && (
|
||||
<div className={classes.actions}>{props.actions}</div>
|
||||
)}
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { EntryCard };
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Paper, Skeleton } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { LoadingSkeleton } from "./LoadingSkeleton";
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Components/LoadingSkeleton",
|
||||
component: LoadingSkeleton,
|
||||
tags: ["autodocs"],
|
||||
args: { label: "Загрузка содержимого" },
|
||||
argTypes: {
|
||||
label: { control: "text" },
|
||||
children: { table: { disable: true } },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Общая status/busy область для initial или partial loading. Передайте доступную метку и только декоративные Skeleton shapes без интерактивных элементов. Геометрию определяет владелец section/page (см. Product/AccountPageLoading). При refetch сохраняйте уже видимые данные; для действия используйте Button loading. Этот пример не запускает запрос и не демонстрирует query lifecycle.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (args) => (
|
||||
<Paper p="md" withBorder>
|
||||
<LoadingSkeleton {...args}>
|
||||
<Skeleton h={24} w="60%" />
|
||||
<Skeleton h={72} />
|
||||
<Skeleton h={16} w="80%" />
|
||||
</LoadingSkeleton>
|
||||
</Paper>
|
||||
),
|
||||
} satisfies Meta<typeof LoadingSkeleton>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Initial: Story = { name: "Загрузка области" };
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Stack } from "@mantine/core";
|
||||
import type { FC, PropsWithChildren } from "react";
|
||||
|
||||
type LoadingSkeletonProps = PropsWithChildren<{
|
||||
label: string;
|
||||
}>;
|
||||
|
||||
const LoadingSkeleton: FC<LoadingSkeletonProps> = (props) => {
|
||||
const { children, label } = props;
|
||||
|
||||
return (
|
||||
<Stack aria-busy="true" aria-label={label} gap="md" role="status">
|
||||
{children}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
export { LoadingSkeleton };
|
||||
@@ -0,0 +1,78 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { ComponentProps } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
import { StateView } from "./StateView";
|
||||
|
||||
type StateStoryArgs = ComponentProps<typeof StateView> & {
|
||||
withAction: boolean;
|
||||
onAction: () => void;
|
||||
};
|
||||
|
||||
const meta = {
|
||||
title: "Design System/Components/StateView",
|
||||
component: StateView,
|
||||
tags: ["autodocs"],
|
||||
args: {
|
||||
title: "Нет учебных данных",
|
||||
text: "Синтетическое пустое состояние. Пользовательские данные не загружаются.",
|
||||
error: false,
|
||||
withAction: false,
|
||||
onAction: fn(),
|
||||
},
|
||||
argTypes: {
|
||||
title: { control: "text" },
|
||||
text: { control: "text" },
|
||||
error: { control: "boolean" },
|
||||
action: { table: { disable: true } },
|
||||
withAction: {
|
||||
control: "boolean",
|
||||
description: "Показать необязательное действие StateView",
|
||||
},
|
||||
onAction: { control: false },
|
||||
},
|
||||
parameters: {
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Общее empty/error состояние WealthLedger с необязательным действием. Error сохраняет alert semantics. Для initial loading используйте skeleton страницы, для refetch — локальное предупреждение рядом с сохранёнными данными. Кнопка вызывает только Actions; API recovery и полный пользовательский сценарий здесь не проверяются.",
|
||||
},
|
||||
},
|
||||
},
|
||||
render: (args) => {
|
||||
const { withAction, onAction, ...stateProps } = args;
|
||||
return (
|
||||
<StateView
|
||||
{...stateProps}
|
||||
action={
|
||||
withAction ? (
|
||||
<Button onClick={onAction} variant="default">
|
||||
{args.error ? "Повторить локально" : "Выполнить пример"}
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
},
|
||||
} satisfies Meta<StateStoryArgs>;
|
||||
|
||||
export default meta;
|
||||
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const Empty: Story = { name: "Пустое состояние" };
|
||||
export const EmptyWithAction: Story = {
|
||||
name: "Пустое состояние с действием",
|
||||
args: { withAction: true },
|
||||
};
|
||||
export const Failed: Story = {
|
||||
name: "Ошибка",
|
||||
args: {
|
||||
error: true,
|
||||
title: "Учебная ошибка",
|
||||
text: "Демонстрация недоступных данных без вызова API.",
|
||||
},
|
||||
};
|
||||
export const ErrorWithAction: Story = {
|
||||
name: "Ошибка с retry",
|
||||
args: { ...Failed.args, withAction: true },
|
||||
};
|
||||
@@ -0,0 +1,37 @@
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { LoadingSkeleton } from "./LoadingSkeleton";
|
||||
import { StateView } from "./StateView";
|
||||
|
||||
describe("StateView", () => {
|
||||
it("renders an accessible structural loading region", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<LoadingSkeleton label="Загрузка">
|
||||
<div />
|
||||
</LoadingSkeleton>
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("status", { name: "Загрузка" })).toHaveAttribute(
|
||||
"aria-busy",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("renders an accessible error state", () => {
|
||||
render(
|
||||
<MantineProvider>
|
||||
<StateView
|
||||
error
|
||||
text="Попробуйте обновить страницу позже."
|
||||
title="Что-то пошло не так"
|
||||
/>
|
||||
,
|
||||
</MantineProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("alert")).toHaveTextContent("Что-то пошло не так");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Paper, Stack, Text, Title } from "@mantine/core";
|
||||
import type { FC, ReactNode } from "react";
|
||||
|
||||
type StateViewProps = {
|
||||
title: string;
|
||||
text: string;
|
||||
action?: ReactNode;
|
||||
error?: boolean;
|
||||
};
|
||||
|
||||
const StateView: FC<StateViewProps> = (props) => {
|
||||
const { title, text, action, error = false } = props;
|
||||
const role = error ? "alert" : undefined;
|
||||
|
||||
return (
|
||||
<Paper p="xl" radius="md" shadow="xs" withBorder role={role}>
|
||||
<Stack align="flex-start" gap="sm">
|
||||
<Title order={2}>{title}</Title>
|
||||
<Text c="dimmed">{text}</Text>
|
||||
{action}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
};
|
||||
|
||||
export { StateView };
|
||||
@@ -0,0 +1,3 @@
|
||||
export { EntryCard } from "./EntryCard";
|
||||
export { LoadingSkeleton } from "./LoadingSkeleton";
|
||||
export { StateView } from "./StateView";
|
||||
@@ -0,0 +1,39 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--wl-color-canvas);
|
||||
color: var(--wl-color-text);
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.mantine-Modal-content {
|
||||
animation: modal-appear 220ms ease-out;
|
||||
}
|
||||
|
||||
.mantine-Modal-overlay {
|
||||
animation: modal-backdrop-appear 220ms ease-out;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.98);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes modal-backdrop-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { DEFAULT_THEME, mergeMantineTheme } from "@mantine/core";
|
||||
import source from "@wealthledger/tokens/tokens.json";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { colorRoles, cssVariablesResolver, theme } from "./theme";
|
||||
|
||||
describe("design theme adapter", () => {
|
||||
it("preserves scale-aware sizes, unscaled breakpoints and shadow layers", () => {
|
||||
expect(theme.spacing?.md).toBe("calc(1rem * var(--mantine-scale))");
|
||||
expect(theme.fontSizes?.sm).toBe("calc(0.875rem * var(--mantine-scale))");
|
||||
expect(theme.breakpoints?.sm).toBe("48em");
|
||||
expect(theme.shadows?.sm).toContain(
|
||||
"calc(-0.3125rem * var(--mantine-scale))",
|
||||
);
|
||||
expect(theme.shadows?.sm).toContain("rgba(0, 0, 0, 0.04)");
|
||||
});
|
||||
|
||||
it("keeps default-shade roles attached to native filled variables", () => {
|
||||
const resolved = mergeMantineTheme(DEFAULT_THEME, theme);
|
||||
const variables = cssVariablesResolver(resolved).variables;
|
||||
expect(colorRoles.primary).toBe("blue");
|
||||
expect(variables["--wl-color-primary"]).toBe(
|
||||
"var(--mantine-color-blue-filled)",
|
||||
);
|
||||
expect(variables["--wl-color-canvas"]).toBe("var(--mantine-color-gray-2)");
|
||||
expect(Object.keys(variables)).toHaveLength(
|
||||
Object.keys(source.color.roles).length,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import "./styles.css";
|
||||
import {
|
||||
type CSSVariablesResolver,
|
||||
colorsTuple,
|
||||
createTheme,
|
||||
getThemeColor,
|
||||
type MantinePrimaryShade,
|
||||
Modal,
|
||||
rem,
|
||||
rgba,
|
||||
} from "@mantine/core";
|
||||
import designTokens from "@wealthledger/tokens/tokens.json";
|
||||
|
||||
const { color, typography, spacing, radius, shadows, breakpoints } =
|
||||
designTokens;
|
||||
const mapValues = <T, U>(values: Record<string, T>, convert: (value: T) => U) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(values).map(([name, value]) => [name, convert(value)]),
|
||||
);
|
||||
|
||||
const colorRoles = mapValues(color.roles, (role) =>
|
||||
role.shade === null ? role.color : `${role.color}.${role.shade}`,
|
||||
);
|
||||
|
||||
const theme = createTheme({
|
||||
respectReducedMotion: true,
|
||||
components: {
|
||||
Modal: Modal.extend({
|
||||
defaultProps: {
|
||||
overlayProps: { blur: 3 },
|
||||
transitionProps: {
|
||||
transition: "fade-down",
|
||||
duration: 220,
|
||||
exitDuration: 180,
|
||||
timingFunction: "ease-out",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
primaryColor: color.roles.primary.color,
|
||||
primaryShade: color.primaryShade as MantinePrimaryShade,
|
||||
defaultRadius: radius.default,
|
||||
white: color.base.white,
|
||||
black: color.base.black,
|
||||
colors: mapValues(color.palettes, colorsTuple),
|
||||
fontFamily: typography.fontFamily,
|
||||
fontFamilyMonospace: typography.fontFamilyMonospace,
|
||||
fontSizes: mapValues(typography.fontSizes, (value) => rem(value)),
|
||||
fontWeights: mapValues(typography.fontWeights, String),
|
||||
lineHeights: mapValues(typography.lineHeights, String),
|
||||
headings: {
|
||||
fontFamily: typography.fontFamily,
|
||||
fontWeight: String(typography.fontWeights.bold),
|
||||
sizes: mapValues(typography.headings, (value) => ({
|
||||
fontSize: rem(value.fontSize),
|
||||
lineHeight: String(value.lineHeight),
|
||||
})),
|
||||
},
|
||||
spacing: mapValues(spacing.values, (value) => rem(value)),
|
||||
radius: mapValues(radius.values, (value) => rem(value)),
|
||||
shadows: mapValues(shadows.values, (layers) =>
|
||||
layers
|
||||
.map(
|
||||
(layer) =>
|
||||
`${rem(layer.offsetX)} ${rem(layer.offsetY)} ${rem(layer.blur)} ${rem(layer.spread)} ${rgba(color.base[layer.color as keyof typeof color.base], layer.opacity)}`,
|
||||
)
|
||||
.join(", "),
|
||||
),
|
||||
breakpoints: mapValues(
|
||||
breakpoints.values,
|
||||
(value) => `${value}${breakpoints.unit}`,
|
||||
),
|
||||
});
|
||||
|
||||
const cssVariablesResolver: CSSVariablesResolver = (resolvedTheme) => ({
|
||||
variables: Object.fromEntries(
|
||||
Object.entries(colorRoles).map(([role, reference]) => [
|
||||
`--wl-color-${role}`,
|
||||
getThemeColor(reference, resolvedTheme),
|
||||
]),
|
||||
),
|
||||
light: {},
|
||||
dark: {},
|
||||
});
|
||||
|
||||
export { colorRoles, cssVariablesResolver, designTokens, theme };
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src/index.ts", "src/theme.ts"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: { index: "src/index.ts", theme: "src/theme.ts" },
|
||||
formats: ["es"],
|
||||
cssFileName: "styles",
|
||||
},
|
||||
rolldownOptions: {
|
||||
external: /^(react(?:-dom)?|@mantine\/[^/]+|@wealthledger\/tokens)(\/|$)/,
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user