codex/design-system-foundation #33
@ -40,3 +40,29 @@ jobs:
|
||||
|
||||
- name: Build frontend
|
||||
run: npm run build:frontend
|
||||
|
||||
- name: Setup Playwright browsers
|
||||
run: npx playwright install --with-deps chromium
|
||||
|
||||
- name: Test design system
|
||||
run: npm run test:design-system
|
||||
|
||||
- name: Build design system
|
||||
run: npm run build:design-system
|
||||
|
||||
- name: Build Storybook
|
||||
run: npm run build:storybook
|
||||
|
||||
- name: Test Storybook (browser)
|
||||
run: npm run test:storybook
|
||||
|
||||
- name: Build docs
|
||||
run: npm run build:docs
|
||||
|
||||
- name: Upload Storybook build (if failed)
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: storybook-static
|
||||
path: packages/design-system/storybook-static
|
||||
retention-days: 3
|
||||
|
||||
28
README.md
28
README.md
@ -21,9 +21,10 @@ npm workspaces монорепозиторий:
|
||||
|
||||
| Пакет | Назначение |
|
||||
| --------------- | -------------------------------------------------- |
|
||||
| `apps/backend` | NestJS API (единственная точка доступа к MOEX ISS) |
|
||||
| `apps/frontend` | React SPA на Vite |
|
||||
| `apps/docs` | Сайт документации Docusaurus |
|
||||
| `apps/backend` | NestJS API (единственная точка доступа к MOEX ISS) |
|
||||
| `apps/frontend` | React SPA на Vite |
|
||||
| `apps/docs` | Сайт документации Docusaurus |
|
||||
| `packages/design-system` | Дизайн-система (Storybook, MUI-адаптер, UI-компоненты) |
|
||||
|
||||
---
|
||||
|
||||
@ -31,6 +32,7 @@ npm workspaces монорепозиторий:
|
||||
|
||||
- **Бэкенд:** NestJS, TypeScript, OpenAPI (Swagger)
|
||||
- **Фронтенд:** React, TypeScript, Vite, TanStack Query, lightweight-charts
|
||||
- **Дизайн-система:** MUI v7, Storybook 10, lightweight-charts
|
||||
- **Документация:** Docusaurus
|
||||
- **Инфраструктура:** Docker, docker-compose
|
||||
|
||||
@ -87,9 +89,11 @@ npm run test:integration -w apps/backend
|
||||
|
||||
```
|
||||
apps/
|
||||
backend/ — NestJS API, единая точка доступа к MOEX ISS
|
||||
frontend/ — React SPA на Vite
|
||||
docs/ — сайт документации Docusaurus
|
||||
backend/ — NestJS API, единая точка доступа к MOEX ISS
|
||||
frontend/ — React SPA на Vite
|
||||
docs/ — сайт документации Docusaurus
|
||||
packages/
|
||||
design-system/ — дизайн-система (MUI-адаптер, UI-компоненты, Storybook)
|
||||
docs/
|
||||
features/ — спецификации и планы реализации (SDD)
|
||||
epics/ — продуктовые эпики
|
||||
@ -105,16 +109,24 @@ docs/
|
||||
| ---------------------------------- | --------------------------------------------------------------------------- |
|
||||
| `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 |
|
||||
| `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 |
|
||||
| `npm run dev:docs` | Docusaurus dev-сервер |
|
||||
| `npm run dev:docs` | Docusaurus dev-сервер (опубликованная документация) |
|
||||
| `npm run build:backend` | `nest build` |
|
||||
| `npm run build:frontend` | `tsc -b && vite build` (в две фазы) |
|
||||
| `npm run build:docs` | `docusaurus build` |
|
||||
| `npm run build:design-system` | Сборка дизайн-системы (`tsc`) |
|
||||
| `npm run build:storybook` | Статическая сборка Storybook |
|
||||
| `npm run test:backend` | `vitest run` (SWC, не ts-jest) |
|
||||
| `npm run test:frontend` | Frontend Vitest suite |
|
||||
| `npm run lint` | ESLint для backend и frontend |
|
||||
| `npm run test:design-system` | Unit-тесты дизайн-системы (Vitest) |
|
||||
| `npm run test:storybook` | Браузерные тесты Storybook (Vitest browser mode + Playwright) |
|
||||
| `npm run storybook` | Storybook dev-сервер на :6006 (инженерный workbench, не docs) |
|
||||
| `npm run lint` | ESLint для backend, frontend и design-system |
|
||||
| `npm run lint:design-system` | ESLint для дизайн-системы |
|
||||
| `npm run format` | Prettier для всех `*.{ts,tsx}` |
|
||||
| `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` |
|
||||
|
||||
Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов.
|
||||
|
||||
Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`.
|
||||
|
||||
Один backend-тест: `npm exec -w apps/backend -- vitest run src/path/to/test.spec.ts`
|
||||
|
||||
79
apps/docs/docs/adr/ADR-016-design-system.md
Normal file
79
apps/docs/docs/adr/ADR-016-design-system.md
Normal file
@ -0,0 +1,79 @@
|
||||
# ADR-016: Дизайн-система — гибридный подход (theme + wrapper-компоненты)
|
||||
|
||||
**Статус:** Accepted
|
||||
|
||||
**Дата:** 2026-06-21
|
||||
|
||||
## Контекст
|
||||
|
||||
Фронтенд MoexVibe нуждался в единообразном UI. Ранее каждая страница использовала MUI напрямую, что приводило к:
|
||||
|
||||
- неконсистентным вариантам компонентов (разные цвета, размеры, отступы);
|
||||
- отсутствию единой системы токенов;
|
||||
- размножению стилей в CSS custom properties.
|
||||
|
||||
Рост количества страниц и компонентов потребовал системного подхода.
|
||||
|
||||
## Рассмотренные варианты
|
||||
|
||||
### 1. Theme-only
|
||||
|
||||
Оставить прямое использование MUI, но настроить единую тему (palette, typography, shape).
|
||||
|
||||
**Плюсы:** минимальные изменения, полная гибкость MUI.
|
||||
**Минусы:** нет гарантии консистентности — разработчики могут использовать любые варианты MUI-компонентов.
|
||||
|
||||
### 2. Hybrid (выбран)
|
||||
|
||||
MUI theme обеспечивает фундамент (палитра, типографика, скругления); wrapper-компоненты поверх MUI гарантируют консистентность и ограничивают варианты.
|
||||
|
||||
**Плюсы:**
|
||||
|
||||
- единый source of truth в теме MUI;
|
||||
- компоненты с ограниченным API (только нужные варианты);
|
||||
- возможность миграции (можно заменить реализацию под капотом);
|
||||
- Box/Stack/Grid остаются как allowlist для лэйаута.
|
||||
|
||||
**Минусы:**
|
||||
|
||||
- необходимо написать 20+ wrapper-компонентов;
|
||||
- двойная прослойка для некоторых сценариев.
|
||||
|
||||
### 3. Full wrapper
|
||||
|
||||
Все UI через собственные компоненты, без прямого использования MUI.
|
||||
|
||||
**Плюсы:** полный контроль, независимость от MUI.
|
||||
**Минусы:** огромный объём работы, дублирование функциональности MUI.
|
||||
|
||||
## Решение
|
||||
|
||||
Выбран гибридный подход (option 2):
|
||||
|
||||
- **Токены:** трёхуровневая система (primitives → semantic → component) с DTCG-резолвером.
|
||||
- **Тема:** MUI 6.5 theme, построенная на семантических токенах. CSS-переменные `--mv-*` для доступа из CSS.
|
||||
- **Компоненты:** 20+ wrapper-компонентов, каждый с ограниченным пропс-интерфейсом.
|
||||
- **ESLint:** `no-restricted-imports` запрещает прямой импорт MUI-компонентов (кроме Box, Stack, Grid).
|
||||
- **Storybook:** инженерный стенд с a11y-проверками.
|
||||
- **Документация:** Docusaurus для опубликованных гайдлайнов (этот сайт).
|
||||
|
||||
## Последствия
|
||||
|
||||
### Положительные
|
||||
|
||||
- Консистентный UI во всех страницах.
|
||||
- Единая система токенов, доступная из CSS (через `--mv-*`).
|
||||
- Изолированная библиотека компонентов, которую можно тестировать и развивать независимо.
|
||||
- Возможность замены реализации под капотом без изменения потребителей.
|
||||
|
||||
### Отрицательные
|
||||
|
||||
- 20+ компонентов нужно поддерживать.
|
||||
- Оверхед на поддержку пропс-интерфейсов (каждый компонент ограничивает MUI API).
|
||||
- Новые разработчики должны знать два слоя (MUI theme + wrapper-компоненты).
|
||||
|
||||
### Future
|
||||
|
||||
- DTCG-экспорт токенов для дизайн-инструментов.
|
||||
- Android-адаптеры для нативных приложений.
|
||||
- Расширение компонентной базы.
|
||||
@ -16,5 +16,7 @@
|
||||
| [ADR-012](ADR-012-frontend-broker-account-aggregation) | Accepted | Агрегация сводки брокерских счетов на frontend |
|
||||
| [ADR-013](ADR-013-frontend-fsd-broker-pilot) | Accepted | Пилотная FSD-миграция broker-домена |
|
||||
| [ADR-014](ADR-014-frontend-fsd-market-pages) | Accepted | FSD-миграция market pages и market widgets |
|
||||
| [ADR-015](ADR-015-frontend-libraries-modernization) | — | Модернизация инфраструктуры фронтенда |
|
||||
| [ADR-016](ADR-016-design-system) | Accepted | Дизайн-система — гибридный подход |
|
||||
|
||||
Все опубликованные ADR находятся в `apps/docs/docs/adr/` и отображаются в этом Docusaurus-разделе.
|
||||
|
||||
49
apps/docs/docs/design-system/accessibility.md
Normal file
49
apps/docs/docs/design-system/accessibility.md
Normal file
@ -0,0 +1,49 @@
|
||||
# Доступность (Accessibility)
|
||||
|
||||
## Целевой уровень
|
||||
|
||||
WCAG 2.2 AA.
|
||||
|
||||
## Требования к компонентам
|
||||
|
||||
### IconButton
|
||||
|
||||
**Требует `label`** — свойство `aria-label` обязательно для всех иконок без текста.
|
||||
|
||||
```tsx
|
||||
<IconButton label="Настройки">
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
```
|
||||
|
||||
### Dialog
|
||||
|
||||
- **Требует `title`** — заголовок, используемый как `aria-labelledby`
|
||||
- Содержит focus trap (фокус не покидает диалог)
|
||||
- Закрывается по Escape
|
||||
|
||||
### Progress
|
||||
|
||||
- `indeterminate`-режим выставляет `aria-busy` на контейнере
|
||||
|
||||
### PriceChange
|
||||
|
||||
- Использует **текстовый индикатор направления** (`+`, `−`, `—`), а не только цвет
|
||||
- Скринридеры получают знак перед числом
|
||||
|
||||
### DataTable
|
||||
|
||||
- **Требует `caption`** — описание таблицы для скринридеров
|
||||
- Семантические заголовки через `<th scope="col">`
|
||||
|
||||
### Skeleton
|
||||
|
||||
- `aria-hidden` по умолчанию — скелетоны не должны озвучиваться
|
||||
|
||||
### Alert
|
||||
|
||||
- Использует `role="alert"` для немедленного объявления скринридером
|
||||
|
||||
### LoadingState
|
||||
|
||||
- `aria-live="polite"` — скринридер объявит об изменении после завершения текущей речи
|
||||
259
apps/docs/docs/design-system/components.md
Normal file
259
apps/docs/docs/design-system/components.md
Normal file
@ -0,0 +1,259 @@
|
||||
# Компоненты
|
||||
|
||||
## Матрица «задача → компонент»
|
||||
|
||||
| Задача | Рекомендуемый компонент | Не использовать |
|
||||
|--------|------------------------|-----------------|
|
||||
| Отображение текста | Text, Heading | MUI Typography напрямую |
|
||||
| Ссылка | Link | MUI Link напрямую |
|
||||
| Действие | Button, IconButton | MUI Button напрямую |
|
||||
| Ввод текста | TextField | MUI TextField напрямую |
|
||||
| Выбор из списка | Select | MUI Select напрямую |
|
||||
| Выбор опции | Checkbox | MUI Checkbox напрямую |
|
||||
| Контейнер/фон | Surface | MUI Paper напрямую |
|
||||
| Карточка | Card | MUI Card напрямую |
|
||||
| Ярлык/тег | Chip | MUI Chip напрямую |
|
||||
| Бейдж | Badge | MUI Badge напрямую |
|
||||
| Уведомление | Alert | MUI Alert напрямую |
|
||||
| Модальное окно | Dialog | MUI Dialog напрямую |
|
||||
| Загрузка скелета | Skeleton | MUI Skeleton напрямую |
|
||||
| Индикатор прогресса | Progress | MUI CircularProgress / LinearProgress напрямую |
|
||||
| Финансовое значение | Money, Metric, PriceChange | сырое число |
|
||||
| Таблица | DataTable | MUI Table напрямую |
|
||||
| Поле формы | FormField | ручная связка label + helperText |
|
||||
| Панель фильтров | FilterBar | ручная вёрстка фильтров |
|
||||
| Пустое состояние | EmptyState | ручная вёрстка |
|
||||
| Ошибка | ErrorState | ручная вёрстка |
|
||||
| Загрузка | LoadingState | ручная вёрстка |
|
||||
|
||||
## Каталог компонентов
|
||||
|
||||
### Text
|
||||
|
||||
Отображение текста с заданным семантическим размером.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `variant` | `'body1' \| 'body2' \| 'caption' \| 'overline'` | `'body1'` |
|
||||
| `color` | `'primary' \| 'secondary' \| 'disabled' \| 'inverse'` | `'primary'` |
|
||||
|
||||
### Heading
|
||||
|
||||
Заголовок.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `variant` | `'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6'` | `'h3'` |
|
||||
|
||||
### Link
|
||||
|
||||
Ссылка. Под капотом MUI Link с цветом `color.action.primary`.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `href` | `string` | — |
|
||||
| `underline` | `'none' \| 'hover' \| 'always'` | `'hover'` |
|
||||
|
||||
### Button
|
||||
|
||||
Кнопка действия. Обёртка MUI Button.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `variant` | `'primary' \| 'secondary' \| 'danger'` | `'primary'` |
|
||||
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` |
|
||||
| `loading` | `boolean` | `false` |
|
||||
|
||||
### IconButton
|
||||
|
||||
Кнопка-иконка. **Требует `label`** (aria-label).
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `label` | `string` | — |
|
||||
| `size` | `'sm' \| 'md'` | `'md'` |
|
||||
|
||||
### TextField
|
||||
|
||||
Текстовое поле ввода.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `size` | `'sm' \| 'md'` | `'md'` |
|
||||
| `fullWidth` | `boolean` | `true` |
|
||||
|
||||
### Select
|
||||
|
||||
Выпадающий список.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `size` | `'sm' \| 'md'` | `'md'` |
|
||||
| `fullWidth` | `boolean` | `true` |
|
||||
|
||||
### Checkbox
|
||||
|
||||
Чекбокс.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `label` | `string` | — |
|
||||
|
||||
### Surface
|
||||
|
||||
Базовый контейнер с фоном и скруглением.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `variant` | `'default' \| 'subtle' \| 'raised'` | `'default'` |
|
||||
|
||||
### Card
|
||||
|
||||
Карточка. Поверхность с тенью.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `padding` | `'sm' \| 'md' \| 'lg'` | `'md'` |
|
||||
|
||||
### Chip
|
||||
|
||||
Компактный элемент для отображения тега или статуса.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `variant` | `'filled' \| 'outlined'` | `'filled'` |
|
||||
| `color` | `'default' \| 'info' \| 'success' \| 'warning' \| 'error'` | `'default'` |
|
||||
|
||||
### Badge
|
||||
|
||||
Бейдж с числовым или текстовым значением.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `variant` | `'dot' \| 'standard'` | `'standard'` |
|
||||
| `color` | `'info' \| 'success' \| 'warning' \| 'error'` | `'info'` |
|
||||
|
||||
### Alert
|
||||
|
||||
Уведомление. Использует `role="alert"`.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `severity` | `'info' \| 'success' \| 'warning' \| 'error'` | `'info'` |
|
||||
| `title` | `string` | — |
|
||||
|
||||
### Dialog
|
||||
|
||||
Модальное окно. **Требует `title`**, содержит focus trap, закрывается по Escape.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `open` | `boolean` | — |
|
||||
| `title` | `string` | — |
|
||||
| `onClose` | `() => void` | — |
|
||||
|
||||
### Skeleton
|
||||
|
||||
Скелетон загрузки. `aria-hidden` по умолчанию.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `width` | `string \| number` | `'100%'` |
|
||||
| `height` | `string \| number` | `20` |
|
||||
|
||||
### Progress
|
||||
|
||||
Индикатор прогресса. Поддерживает indeterminate-режим с `aria-busy`.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `variant` | `'determinate' \| 'indeterminate'` | `'indeterminate'` |
|
||||
| `value` | `number` (0–100) | `0` |
|
||||
|
||||
### DataTable
|
||||
|
||||
Таблица данных на основе TanStack Table.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `columns` | `ColumnDef<T>[]` | — |
|
||||
| `data` | `T[]` | — |
|
||||
| `caption` | `string` | — |
|
||||
| `density` | `'balanced' \| 'compact'` | `'balanced'` |
|
||||
|
||||
### Money
|
||||
|
||||
Финансовое значение с форматированием через `Intl.NumberFormat`.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `value` | `number` | — |
|
||||
| `currency` | `string` | `'RUB'` |
|
||||
| `locale` | `string` | `'ru-RU'` |
|
||||
|
||||
### Metric
|
||||
|
||||
Метрика: лейбл + значение + опционально supporting/trend.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `label` | `string` | — |
|
||||
| `value` | `ReactNode` | — |
|
||||
| `supporting` | `ReactNode` | — |
|
||||
| `trend` | `'up' \| 'down' \| 'neutral'` | — |
|
||||
|
||||
### PriceChange
|
||||
|
||||
Изменение цены. Использует **текстовое направление** (не только цвет).
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `value` | `number` | — |
|
||||
| `percent` | `number` | — |
|
||||
| `variant` | `'absolute' \| 'percent' \| 'both'` | `'both'` |
|
||||
|
||||
### FormField
|
||||
|
||||
Обёртка для поля формы: label + helperText + error.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `label` | `string` | — |
|
||||
| `error` | `string` | — |
|
||||
| `helperText` | `string` | — |
|
||||
|
||||
### FilterBar
|
||||
|
||||
Панель фильтров с responsive-обёрткой.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `children` | `ReactNode` | — |
|
||||
|
||||
### EmptyState
|
||||
|
||||
Пустое состояние. **Требует `title`**.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `title` | `string` | — |
|
||||
| `description` | `string` | — |
|
||||
| `action` | `ReactNode` | — |
|
||||
|
||||
### ErrorState
|
||||
|
||||
Состояние ошибки. **Требует `title`**.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `title` | `string` | — |
|
||||
| `description` | `string` | — |
|
||||
| `onRetry` | `() => void` | — |
|
||||
|
||||
### LoadingState
|
||||
|
||||
Состояние загрузки. `aria-live="polite"`.
|
||||
|
||||
| Свойство | Тип | По умолчанию |
|
||||
|----------|-----|--------------|
|
||||
| `title` | `string` | — |
|
||||
52
apps/docs/docs/design-system/foundations.md
Normal file
52
apps/docs/docs/design-system/foundations.md
Normal file
@ -0,0 +1,52 @@
|
||||
# Фундаментальные токены
|
||||
|
||||
## Архитектура
|
||||
|
||||
Система токенов состоит из трёх уровней:
|
||||
|
||||
```text
|
||||
Primitives ──► Semantic ──► Component
|
||||
(сырые (алиасы (алиасы для
|
||||
значения) назначения) компонентов)
|
||||
```
|
||||
|
||||
### Primitives
|
||||
|
||||
Хранят абсолютные значения: конкретные цвета, размеры, шрифты. Не несут семантической нагрузки.
|
||||
|
||||
Пример: `color.neutral.900` → `#1a1a1a`, `space.4` → `32px`.
|
||||
|
||||
### Semantic
|
||||
|
||||
Алиасы, ссылающиеся на примитивы и описывающие назначение. Используются в приложении и компонентах.
|
||||
|
||||
Пример: `color.text.primary` → `{color.neutral.900}` → `#1a1a1a`.
|
||||
|
||||
### Component
|
||||
|
||||
Алиасы для конкретных компонентов. Позволяют переопределять токены на уровне компонента без изменения семантических токенов.
|
||||
|
||||
Пример: `button.bg` → `{color.action.primary}` → `{color.green.700}`.
|
||||
|
||||
## Резолвер
|
||||
|
||||
Используется DTCG-подобный резолвер (Design Tokens Community Group):
|
||||
|
||||
- Цепочная резолюция: `component → semantic → primitive`
|
||||
- Проверка циклов
|
||||
- Проверка типов (`$type` должен совпадать)
|
||||
- Кэширование результатов
|
||||
|
||||
## Токены v1
|
||||
|
||||
В версии 1 все токены светлые (light-only). Ключевые группы:
|
||||
|
||||
- **color** — цвета (canvas, surface, text, border, action, feedback, finance)
|
||||
- **typography** — шрифты (family, weight)
|
||||
- **spacing** — отступы (inset, stack, inline)
|
||||
- **shape** — скругления (в примитивах)
|
||||
- **shadow** — тени (в примитивах)
|
||||
- **duration** — длительности анимаций (в примитивах)
|
||||
- **easing** — функции сглаживания (в примитивах)
|
||||
|
||||
Полный список семантических токенов — [Токены](tokens).
|
||||
38
apps/docs/docs/design-system/governance.md
Normal file
38
apps/docs/docs/design-system/governance.md
Normal file
@ -0,0 +1,38 @@
|
||||
# Управление (Governance)
|
||||
|
||||
## Режим v1
|
||||
|
||||
- **Только светлая тема**. Тёмная тема отложена до v2.
|
||||
- Изменения, затрагивающие цветовую палитру или контрастность, должны проверяться на WCAG 2.2 AA.
|
||||
|
||||
## Процесс добавления компонентов
|
||||
|
||||
Новый компонент проходит полный цикл:
|
||||
|
||||
1. **Spec** — спецификация API компонента
|
||||
2. **Plan** — план реализации
|
||||
3. **TDD** — тесты пишутся до реализации
|
||||
4. **Storybook** — интерактивный каталог с a11y-аддоном
|
||||
5. **Review** — код-ревью с проверкой accessibility
|
||||
|
||||
## Breaking changes
|
||||
|
||||
Любое изменение публичного API компонента или токена требует:
|
||||
|
||||
- Обновления мажорной версии
|
||||
- Миграционной заметки в `CHANGELOG.md`
|
||||
|
||||
## Импорты MUI
|
||||
|
||||
Прямые импорты из MUI в коде приложения (`apps/frontend`) запрещены, за исключением allowlist:
|
||||
|
||||
| Компонент | Разрешён |
|
||||
|-----------|----------|
|
||||
| Box | ✅ |
|
||||
| Stack | ✅ |
|
||||
| Grid | ✅ |
|
||||
| Typography | ❌ → использовать Text / Heading |
|
||||
| Button | ❌ → использовать Button |
|
||||
| Остальные MUI | ❌ |
|
||||
|
||||
Нарушение контролируется ESLint-правилом `no-restricted-imports` (уровень `warn`).
|
||||
42
apps/docs/docs/design-system/overview.md
Normal file
42
apps/docs/docs/design-system/overview.md
Normal file
@ -0,0 +1,42 @@
|
||||
# Дизайн-система
|
||||
|
||||
## Что это
|
||||
|
||||
`@moex-vibe/design-system` — внутренний пакет дизайн-системы (v0.1.0). Построен на [MUI 6.5](https://mui.com/material-ui/). Поддерживает только светлую тему (light-only).
|
||||
|
||||
## Архитектура токенов
|
||||
|
||||
Трёхуровневая система токенов:
|
||||
|
||||
1. **Primitives** — сырые значения (цвета, размеры, шрифты)
|
||||
2. **Semantic** — алиасы, описывающие назначение (`color.text.primary`)
|
||||
3. **Component** — алиасы для конкретных компонентов (`button.bg`)
|
||||
|
||||
Подробнее — [Токены](foundations).
|
||||
|
||||
## Компоненты
|
||||
|
||||
Библиотека включает 25 компонентов:
|
||||
|
||||
| Категория | Компоненты |
|
||||
|-----------|------------|
|
||||
| Typography | Text, Heading, Link |
|
||||
| Actions | Button, IconButton |
|
||||
| Inputs | TextField, Select, Checkbox |
|
||||
| Surfaces | Surface, Card |
|
||||
| Feedback | Chip, Badge, Alert, Dialog, Skeleton, Progress |
|
||||
| Financial | Money, Metric, PriceChange |
|
||||
| Form | FormField, FilterBar |
|
||||
| Page States | EmptyState, ErrorState, LoadingState |
|
||||
| Data | DataTable |
|
||||
|
||||
Подробнее — [Компоненты](components).
|
||||
|
||||
## Инструменты
|
||||
|
||||
- **Storybook** — инженерный стенд для разработки и визуального ревью
|
||||
- **Docusaurus** — опубликованная документация (этот сайт)
|
||||
|
||||
## ADR
|
||||
|
||||
Архитектурное решение описано в [ADR-016](../adr/ADR-016-design-system).
|
||||
94
apps/docs/docs/design-system/patterns.md
Normal file
94
apps/docs/docs/design-system/patterns.md
Normal file
@ -0,0 +1,94 @@
|
||||
# Паттерны
|
||||
|
||||
## Финансовые данные
|
||||
|
||||
### Money
|
||||
|
||||
Форматирование через `Intl.NumberFormat`:
|
||||
|
||||
- Локаль: `ru-RU`
|
||||
- Валюта по умолчанию: `RUB`
|
||||
|
||||
```tsx
|
||||
<Money value={1234.5} /> // 1 234,50 ₽
|
||||
<Money value={1234.5} currency="USD" /> // 1 234,50 $
|
||||
```
|
||||
|
||||
### PriceChange
|
||||
|
||||
Отображает изменение цены. Обязательное правило: **никогда не использовать только цвет**. Всегда добавлять текстовый индикатор направления (`+`, `−`, `—`).
|
||||
|
||||
| Направление | Индикатор | Цвет токена |
|
||||
|-------------|-----------|-------------|
|
||||
| Рост | `+` | `color.finance.positive` |
|
||||
| Падение | `−` | `color.finance.negative` |
|
||||
| Нейтрально | `—` | `color.finance.neutral` |
|
||||
|
||||
### Metric
|
||||
|
||||
Композитный компонент: label + value + опционально supporting (доп. текст) и trend (направление).
|
||||
|
||||
```tsx
|
||||
<Metric
|
||||
label="Общая доходность"
|
||||
value={<Money value={12345} />}
|
||||
trend="up"
|
||||
/>
|
||||
```
|
||||
|
||||
## Формы
|
||||
|
||||
### FormField
|
||||
|
||||
Обёртка для поля, связывающая label, helperText и error через ассоциацию `htmlFor`/`id`.
|
||||
|
||||
```tsx
|
||||
<FormField label="Название" error="Обязательное поле">
|
||||
<TextField />
|
||||
</FormField>
|
||||
```
|
||||
|
||||
### FilterBar
|
||||
|
||||
Responsive-панель фильтров с автоматическим переносом (wrap).
|
||||
|
||||
```tsx
|
||||
<FilterBar>
|
||||
<Select label="Тип" />
|
||||
<TextField label="Поиск" />
|
||||
</FilterBar>
|
||||
```
|
||||
|
||||
## Состояния страницы
|
||||
|
||||
### EmptyState, ErrorState, LoadingState
|
||||
|
||||
Три компонента для трёх состояний любой страницы или секции:
|
||||
|
||||
- **EmptyState** — данных нет, `title` обязателен
|
||||
- **ErrorState** — ошибка загрузки, `title` обязателен, `onRetry` для повтора
|
||||
- **LoadingState** — загрузка, `title` обязателен, `aria-live="polite"`
|
||||
|
||||
```tsx
|
||||
{isLoading && <LoadingState title="Загрузка портфеля" />}
|
||||
{error && <ErrorState title="Ошибка загрузки" onRetry={refetch} />}
|
||||
{data && data.length === 0 && <EmptyState title="Нет операций" />}
|
||||
```
|
||||
|
||||
## Таблицы
|
||||
|
||||
### DataTable
|
||||
|
||||
Обёртка над TanStack Table с обязательным `caption` для доступности. Два режима плотности:
|
||||
|
||||
- **balanced** (по умолчанию) — отступы md
|
||||
- **compact** — отступы sm, для плотных таблиц
|
||||
|
||||
```tsx
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={rows}
|
||||
caption="Список операций"
|
||||
density="compact"
|
||||
/>
|
||||
```
|
||||
106
apps/docs/docs/design-system/tokens.md
Normal file
106
apps/docs/docs/design-system/tokens.md
Normal file
@ -0,0 +1,106 @@
|
||||
# Семантические токены
|
||||
|
||||
Компонентные токены документированы на страницах соответствующих компонентов.
|
||||
|
||||
## Color
|
||||
|
||||
### Canvas
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `color.canvas` | `{color.neutral.50}` | `#f5f5f5` |
|
||||
|
||||
### Surface
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `color.surface.default` | `{color.neutral.0}` | `#ffffff` |
|
||||
| `color.surface.subtle` | `{color.neutral.50}` | `#f5f5f5` |
|
||||
| `color.surface.raised` | `{color.neutral.0}` | `#ffffff` |
|
||||
|
||||
### Text
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `color.text.primary` | `{color.neutral.900}` | `#1a1a1a` |
|
||||
| `color.text.secondary` | `{color.neutral.600}` | `#666666` |
|
||||
| `color.text.disabled` | `{color.neutral.400}` | `#999999` |
|
||||
| `color.text.inverse` | `{color.neutral.0}` | `#ffffff` |
|
||||
|
||||
### Border
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `color.border.subtle` | `{color.neutral.100}` | `#e0e0e0` |
|
||||
| `color.border.default` | `{color.neutral.200}` | `#cccccc` |
|
||||
| `color.border.strong` | `{color.neutral.400}` | `#999999` |
|
||||
| `color.border.focus` | `{color.blue.600}` | `#1e88e5` |
|
||||
|
||||
### Action
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `color.action.primary` | `{color.green.700}` | `#388e3c` |
|
||||
| `color.action.primaryHover` | `{color.green.800}` | `#2e7d32` |
|
||||
| `color.action.secondary` | `{color.neutral.600}` | `#666666` |
|
||||
| `color.action.danger` | `{color.red.600}` | `#e53935` |
|
||||
|
||||
### Feedback
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `color.feedback.info` | `{color.blue.600}` | `#1e88e5` |
|
||||
| `color.feedback.success` | `{color.green.600}` | `#43a047` |
|
||||
| `color.feedback.warning` | `{color.amber.600}` | `#ffb300` |
|
||||
| `color.feedback.error` | `{color.red.600}` | `#e53935` |
|
||||
|
||||
### Finance
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `color.finance.positive` | `{color.green.700}` | `#388e3c` |
|
||||
| `color.finance.negative` | `{color.red.700}` | `#d32f2f` |
|
||||
| `color.finance.neutral` | `{color.neutral.600}` | `#666666` |
|
||||
|
||||
## Typography
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `font.family.body` | `{font.family.sans}` | `Inter, system-ui, sans-serif` |
|
||||
| `font.weight.body` | `{font.weight.regular}` | `400` |
|
||||
| `font.weight.heading` | `{font.weight.semibold}` | `600` |
|
||||
| `font.weight.strong` | `{font.weight.bold}` | `700` |
|
||||
|
||||
## Spacing
|
||||
|
||||
### Inset (внутренние отступы)
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `space.inset.sm` | `{space.2}` | `8px` |
|
||||
| `space.inset.md` | `{space.3}` | `12px` |
|
||||
| `space.inset.lg` | `{space.4}` | `16px` |
|
||||
|
||||
### Stack (вертикальные отступы)
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `space.stack.sm` | `{space.1}` | `4px` |
|
||||
| `space.stack.md` | `{space.2}` | `8px` |
|
||||
| `space.stack.lg` | `{space.4}` | `16px` |
|
||||
|
||||
### Inline (горизонтальные отступы)
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `space.inline.sm` | `{space.1}` | `4px` |
|
||||
| `space.inline.md` | `{space.2}` | `8px` |
|
||||
| `space.inline.lg` | `{space.3}` | `12px` |
|
||||
|
||||
## Size / Focus
|
||||
|
||||
| Токен | Значение | Разрешение |
|
||||
|-------|----------|------------|
|
||||
| `focus.ring.width` | `{size.focus.ring}` | `2px` |
|
||||
| `focus.ring.offset` | `{size.focus.offset}` | `2px` |
|
||||
| `focus.ring.color` | `{color.blue.600}` | `#1e88e5` |
|
||||
@ -2,7 +2,17 @@
|
||||
|
||||
## Подход
|
||||
|
||||
CSS через единый `styles.css` с CSS custom properties. Без CSS-in-JS или Tailwind.
|
||||
Управление стилями осуществляется через пакет [дизайн-системы](../design-system/overview) `@moex-vibe/design-system`, который предоставляет:
|
||||
|
||||
- Систему токенов (primitives → semantic → component)
|
||||
- MUI 6.5 тему, построенную на токенах
|
||||
- CSS-переменные `--mv-*` для доступа из обычного CSS
|
||||
|
||||
Все новые компоненты должны использовать токены дизайн-системы. Подробнее — [Дизайн-система](../design-system/overview).
|
||||
|
||||
## Legacy: CSS custom properties
|
||||
|
||||
Ранее стили определялись через единый `styles.css` с CSS custom properties. Этот подход считается устаревшим — новые страницы должны использовать токены дизайн-системы.
|
||||
|
||||
## CSS custom properties
|
||||
|
||||
|
||||
@ -39,6 +39,19 @@ const sidebars: SidebarsConfig = {
|
||||
label: 'Инфраструктура',
|
||||
items: ['infrastructure/docker', 'infrastructure/ci'],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Дизайн-система',
|
||||
items: [
|
||||
'design-system/overview',
|
||||
'design-system/foundations',
|
||||
'design-system/tokens',
|
||||
'design-system/components',
|
||||
'design-system/patterns',
|
||||
'design-system/accessibility',
|
||||
'design-system/governance',
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'category',
|
||||
label: 'Разработка',
|
||||
|
||||
@ -29,6 +29,12 @@ module.exports = {
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'],
|
||||
rules: {
|
||||
'no-restricted-imports': ['warn', {
|
||||
paths: [{
|
||||
name: '@mui/material',
|
||||
message: 'Import from @moex-vibe/design-system instead.',
|
||||
}],
|
||||
}],
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
|
||||
@ -15,7 +15,8 @@
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@fontsource/roboto": "^5.2.10",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@moex-vibe/design-system": "*",
|
||||
"@hookform/resolvers": "^3.10.0",
|
||||
"@mui/icons-material": "^6.5.0",
|
||||
"@mui/material": "^6.5.0",
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
import { type ReactNode } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import '@fontsource/inter/400.css';
|
||||
import '@fontsource/inter/500.css';
|
||||
import '@fontsource/inter/600.css';
|
||||
import '@fontsource/inter/700.css';
|
||||
import { MoexVibeThemeProvider } from '@moex-vibe/design-system/theme';
|
||||
import { SessionProvider } from './SessionProvider';
|
||||
import { theme } from '@/app/styles/theme';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
@ -17,11 +19,10 @@ const queryClient = new QueryClient({
|
||||
|
||||
export function AppProviders({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<ThemeProvider theme={theme} defaultMode="light">
|
||||
<CssBaseline />
|
||||
<MoexVibeThemeProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</QueryClientProvider>
|
||||
</ThemeProvider>
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
export const theme = createTheme({
|
||||
cssVariables: true,
|
||||
colorSchemes: {
|
||||
light: {
|
||||
palette: {
|
||||
primary: { main: '#1976d2' },
|
||||
success: { main: '#2e7d32' },
|
||||
error: { main: '#c62828' },
|
||||
background: { default: '#f5f5f5', paper: '#ffffff' },
|
||||
},
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: ['-apple-system', 'BlinkMacSystemFont', "'Segoe UI'", 'Roboto', 'sans-serif'].join(
|
||||
',',
|
||||
),
|
||||
},
|
||||
});
|
||||
643
docs/features/design-system-foundation/plan.md
Normal file
643
docs/features/design-system-foundation/plan.md
Normal file
@ -0,0 +1,643 @@
|
||||
# Design System Foundation Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Создать внутренний пакет `@moex-vibe/design-system` с трёхуровневыми токенами, MUI light theme, согласованным UI-каталогом, Storybook и опубликованными правилами.
|
||||
|
||||
**Architecture:** Platform-neutral токены хранятся как JSON-compatible TypeScript records и разрешаются собственным проверяемым resolver. MUI-адаптер переводит semantic/component tokens в CSS-variable theme; React-компоненты доступны только через public exports пакета. Storybook служит локальным/CI workbench, Docusaurus — единственной опубликованной документацией.
|
||||
|
||||
**Tech Stack:** TypeScript 5, React 18, MUI 6.5, TanStack Table 8, Vitest 4 browser mode, Storybook 10.2 (`@storybook/react-vite`), Playwright Chromium, Docusaurus 3.7.
|
||||
|
||||
---
|
||||
|
||||
## Зафиксированные интерфейсы
|
||||
|
||||
Пакет предоставляет subpath exports:
|
||||
|
||||
```json
|
||||
{
|
||||
".": "./dist/index.js",
|
||||
"./tokens": "./dist/tokens/index.js",
|
||||
"./theme": "./dist/theme/index.js"
|
||||
}
|
||||
```
|
||||
|
||||
Token source использует плоские DTCG-подобные записи:
|
||||
|
||||
```ts
|
||||
type TokenType = 'color' | 'dimension' | 'fontFamily' | 'fontWeight' | 'duration' | 'cubicBezier' | 'shadow';
|
||||
type TokenValue = string | number | readonly number[] | readonly string[];
|
||||
type TokenDefinition = { readonly $type: TokenType; readonly $value: TokenValue };
|
||||
type TokenCollection = Readonly<Record<string, TokenDefinition>>;
|
||||
|
||||
// Primitive содержит абсолютное значение.
|
||||
'color.green.700': { $type: 'color', $value: '#176747' }
|
||||
// Semantic/component содержит alias и никогда не дублирует primitive.
|
||||
'color.action.primary': { $type: 'color', $value: '{color.green.700}' }
|
||||
```
|
||||
|
||||
Публичный React API:
|
||||
|
||||
```ts
|
||||
type Tone = 'default' | 'secondary' | 'positive' | 'negative' | 'muted';
|
||||
type Density = 'balanced' | 'compact';
|
||||
type ActionVariant = 'primary' | 'secondary' | 'tertiary' | 'danger';
|
||||
|
||||
type TextProps = { variant?: 'body' | 'caption' | 'label' | 'numeric'; tone?: Tone };
|
||||
type HeadingProps = { level: 1 | 2 | 3 | 4 | 5 | 6; size?: 'display' | 'title' | 'section' | 'subsection' };
|
||||
type ButtonProps = { variant?: ActionVariant; size?: 'small' | 'medium'; loading?: boolean };
|
||||
type IconButtonProps = { label: string; size?: 'small' | 'medium' };
|
||||
type SurfaceProps = { padding?: 'none' | 'sm' | 'md' | 'lg'; elevation?: 'none' | 'sm' | 'md' };
|
||||
type DataTableProps<T> = { table: Table<T>; density?: Density; loading?: boolean; empty?: ReactNode; caption: string };
|
||||
type MoneyProps = { value: number; currency?: string; locale?: string; signDisplay?: 'auto' | 'always' | 'never' };
|
||||
type PriceChangeProps = { value: number; format?: 'percent' | 'money'; currency?: string; locale?: string };
|
||||
type MetricProps = { label: string; value: ReactNode; supportingText?: ReactNode; trend?: ReactNode };
|
||||
```
|
||||
|
||||
Остальные контракты фиксированы так:
|
||||
|
||||
- `Link`: MUI Link без `color`, `variant`, `sx`; добавляет `tone` и сохраняет polymorphic `component`.
|
||||
- `TextField`: MUI TextField без `color`, `variant`, `size`, `sx`; всегда `variant="outlined"` и
|
||||
`size="medium"`.
|
||||
- `Select`: `label`, `value`, `onChange(value: string)`, `options: { value; label; disabled? }[]`,
|
||||
`error?`, `helperText?`, `disabled?`.
|
||||
- `Checkbox`: `label`, `checked`, `onChange(checked: boolean)`, `disabled?`, `error?`.
|
||||
- `Card`: Surface props плюс `header?`, `actions?`, `children`; не знает маршруты и домен.
|
||||
- `Chip`: `label`, `tone: neutral|info|success|warning|error`, `onDelete?`.
|
||||
- `Badge`: `value`, `max?`, `label`; decorative badge запрещён без accessible label.
|
||||
- `Alert`: `severity: info|success|warning|error`, `title?`, `children`, `action?`.
|
||||
- `Dialog`: `open`, `onClose`, обязательные `title`, `children`, `actions?`.
|
||||
- `Skeleton`: `width?`, `height?`, `shape: text|rectangular|rounded|circular`.
|
||||
- `Progress`: `label`, `value?`; отсутствие value означает indeterminate.
|
||||
- `FormField`: `label`, `htmlFor`, `helperText?`, `error?`, `required?`, `children`.
|
||||
- `FilterBar`: `children`, `actions?`; отвечает только за responsive layout.
|
||||
- Page states: обязательный `title`, optional `description/action`; `LoadingState` дополнительно имеет
|
||||
`label` и `size: section|page`.
|
||||
|
||||
Все wrappers запрещают произвольные `color`, `variant`, `size` и `sx`, где они обходят контракт.
|
||||
`Box`, `Stack`, `Grid` остаются полным и единственным прямым MUI allowlist во frontend.
|
||||
|
||||
## Карта файлов
|
||||
|
||||
- `packages/design-system/src/tokens/` — schema, три token collections, resolver и guards.
|
||||
- `packages/design-system/src/theme/` — MUI augmentation, theme factory, provider, component overrides.
|
||||
- `packages/design-system/src/components/` — Core UI и product patterns; один каталог на компонент.
|
||||
- `packages/design-system/.storybook/` — единый provider, a11y и browser-test annotations.
|
||||
- `apps/frontend/src/app/providers/AppProviders.tsx` — подключение provider пакета без миграции страниц.
|
||||
- `apps/docs/docs/design-system/` — канонические правила; `apps/docs/docs/adr/ADR-016-design-system.md` — архитектурное решение.
|
||||
|
||||
### Task 1: Pre-flight и workspace shell
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json`
|
||||
- Create: `packages/design-system/package.json`
|
||||
- Create: `packages/design-system/tsconfig.json`
|
||||
- Create: `packages/design-system/vitest.config.ts`
|
||||
- Create: `packages/design-system/.eslintrc.cjs`
|
||||
- Create: `packages/design-system/src/test/setup.ts`
|
||||
- Create: `packages/design-system/src/index.ts`
|
||||
|
||||
- [ ] **Step 1: Проверить pre-flight до любых implementation edits**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git branch --show-current
|
||||
git status --short
|
||||
npm run test:backend
|
||||
npm run test:frontend
|
||||
npm run lint
|
||||
npm run build:backend
|
||||
npm run build:frontend
|
||||
```
|
||||
|
||||
Expected: ветка `codex/design-system-foundation`, чистый worktree и все команды PASS. При сбое
|
||||
остановиться и классифицировать его до изменения кода.
|
||||
|
||||
- [ ] **Step 2: Добавить workspace и root scripts**
|
||||
|
||||
В `package.json` добавить `packages/design-system` в `workspaces` и scripts:
|
||||
|
||||
```json
|
||||
{
|
||||
"build:design-system": "npm run build -w packages/design-system",
|
||||
"test:design-system": "npm run test -w packages/design-system",
|
||||
"lint:design-system": "npm run lint -w packages/design-system",
|
||||
"storybook": "npm run storybook -w packages/design-system",
|
||||
"build:storybook": "npm run build-storybook -w packages/design-system",
|
||||
"test:storybook": "npm run test:storybook -w packages/design-system"
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Создать package manifest и TypeScript build**
|
||||
|
||||
`packages/design-system/package.json` должен быть private ESM package версии `0.1.0`, иметь `files:
|
||||
["dist"]`, exports из раздела выше, peer dependencies на React 18, MUI 6 и TanStack Table 8, dev
|
||||
dependencies Storybook `^10.2.9`, `@vitest/browser-playwright`/Vitest `^4.1.8` и Playwright, scripts
|
||||
`build: tsc -p tsconfig.json`, `test: vitest run --project unit`, `storybook: storybook dev -p 6006
|
||||
--no-open`, `build-storybook: storybook build`, `test:storybook: vitest run --project storybook`,
|
||||
`lint: eslint "src/**/*.{ts,tsx}"`.
|
||||
|
||||
`tsconfig.json` компилирует `src` в `dist`, включает declarations, `jsx: react-jsx`, strict и
|
||||
`moduleResolution: bundler`. `vitest.config.ts` сначала содержит unit project с jsdom и
|
||||
`passWithNoTests: true`; флаг удаляется после Task 2. Test setup подключает jest-dom и matchMedia mock.
|
||||
Package ESLint config проверяет TypeScript/React hooks, но не включает frontend FSD zones. Root `lint`
|
||||
дополняется `lint:design-system`.
|
||||
|
||||
- [ ] **Step 4: Установить зависимости и проверить пустой пакет**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build:design-system
|
||||
npm run test:design-system
|
||||
```
|
||||
|
||||
Expected: package-lock обновлён, build PASS, Vitest PASS с `passWithNoTests: true`.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json packages/design-system
|
||||
git commit -m "build(design-system): add workspace package"
|
||||
```
|
||||
|
||||
### Task 2: Token schema и resolver (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/design-system/src/tokens/types.ts`
|
||||
- Create: `packages/design-system/src/tokens/resolveToken.ts`
|
||||
- Test: `packages/design-system/src/tokens/resolveToken.test.ts`
|
||||
|
||||
- [ ] **Step 1: Написать failing tests**
|
||||
|
||||
```ts
|
||||
it('resolves an alias chain and preserves the declared type', () => {
|
||||
const tokens = {
|
||||
base: { $type: 'color', $value: '#176747' },
|
||||
semantic: { $type: 'color', $value: '{base}' },
|
||||
component: { $type: 'color', $value: '{semantic}' },
|
||||
} satisfies TokenCollection;
|
||||
expect(resolveToken(tokens, 'component')).toEqual({ type: 'color', value: '#176747' });
|
||||
});
|
||||
|
||||
it.each([
|
||||
['missing alias', { a: { $type: 'color', $value: '{missing}' } }, /Unknown token/],
|
||||
['cycle', { a: { $type: 'color', $value: '{b}' }, b: { $type: 'color', $value: '{a}' } }, /cycle/i],
|
||||
['type mismatch', { a: { $type: 'color', $value: '#fff' }, b: { $type: 'dimension', $value: '{a}' } }, /type/i],
|
||||
])('rejects %s', (_name, tokens, error) => expect(() => resolveToken(tokens, 'a')).toThrow(error));
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест и подтвердить RED**
|
||||
|
||||
Run: `npm run test:design-system -- resolveToken.test.ts`
|
||||
|
||||
Expected: FAIL, module/functions отсутствуют.
|
||||
|
||||
- [ ] **Step 3: Реализовать schema и resolver**
|
||||
|
||||
Resolver распознаёт только полную alias-строку `/^\{([^}]+)\}$/`, хранит visited path, проверяет
|
||||
существование target и равенство `$type`, возвращает `{ type, value }`. Смешанные строки вроде
|
||||
`calc({space.2} * 2)` запрещены.
|
||||
|
||||
- [ ] **Step 4: Запустить тест и commit**
|
||||
|
||||
Run: `npm run test:design-system -- resolveToken.test.ts`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
```bash
|
||||
git add packages/design-system/src/tokens
|
||||
git commit -m "feat(design-system): add token contracts"
|
||||
```
|
||||
|
||||
### Task 3: Три уровня токенов
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/design-system/src/tokens/primitives.ts`
|
||||
- Create: `packages/design-system/src/tokens/semantic.light.ts`
|
||||
- Create: `packages/design-system/src/tokens/components.ts`
|
||||
- Create: `packages/design-system/src/tokens/index.ts`
|
||||
- Test: `packages/design-system/src/tokens/tokens.test.ts`
|
||||
|
||||
- [ ] **Step 1: Написать token integrity tests**
|
||||
|
||||
Тест объединяет три collections, резолвит каждый token, проверяет уникальные имена и инварианты:
|
||||
|
||||
```ts
|
||||
expect(Object.keys(primitiveTokens).every((name) => !isAlias(primitiveTokens[name].$value))).toBe(true);
|
||||
expect(Object.keys(semanticLightTokens).every((name) => isAlias(semanticLightTokens[name].$value))).toBe(true);
|
||||
expect(Object.keys(componentTokens).every((name) => isAlias(componentTokens[name].$value))).toBe(true);
|
||||
for (const name of Object.keys(allTokens)) expect(() => resolveToken(allTokens, name)).not.toThrow();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Запустить тест и подтвердить RED**
|
||||
|
||||
Run: `npm run test:design-system -- tokens.test.ts`
|
||||
|
||||
Expected: FAIL, collections отсутствуют.
|
||||
|
||||
- [ ] **Step 3: Добавить минимальный полный набор foundations**
|
||||
|
||||
Primitive groups: `color.neutral.{0,50,100,200,400,600,800,900}`, `color.green.{50,100,600,700,800}`,
|
||||
`color.red.{50,600,700}`, `color.amber.{50,600}`, `color.blue.{50,600}`, `space.{0,1,2,3,4,5,6,8,10,12}`
|
||||
на 4px grid, `radius.{none,sm,md,lg,pill}`, `font.family.{sans,mono}`, `font.size.{100..700}`,
|
||||
`font.weight.{regular,medium,semibold,bold}`, `lineHeight.{tight,normal,relaxed}`, `shadow.{none,sm,md}`,
|
||||
`duration.{instant,fast,normal}`, `easing.standard`, `size.control.{sm,md}`.
|
||||
|
||||
`font.family.sans` использует Inter с system-ui fallback. Webfont weights 400/500/600/700 подключаются
|
||||
один раз во frontend shell в Task 10; Roboto не становится частью нового foundation.
|
||||
|
||||
Semantic groups: `color.canvas`, `color.surface.{default,subtle,raised}`, `color.text.{primary,secondary,
|
||||
disabled,inverse}`, `color.border.{subtle,default,strong,focus}`, `color.action.{primary,primaryHover,
|
||||
secondary,danger}`, `color.feedback.{info,success,warning,error}`, `color.finance.{positive,negative,
|
||||
neutral}`, plus semantic typography, focus ring, spacing and control sizes.
|
||||
|
||||
Component groups cover только API v1: button, iconButton, field, checkbox, surface, card, chip, badge,
|
||||
alert, dialog, skeleton, progress, table, metric и pageState.
|
||||
|
||||
- [ ] **Step 4: Проверить GREEN и запрет hardcoded values вне primitives**
|
||||
|
||||
Run: `npm run test:design-system -- tokens.test.ts`
|
||||
|
||||
Expected: PASS. Дополнительный test scan подтверждает, что `semantic.light.ts` и `components.ts` не
|
||||
содержат hex/rgb/px literals.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/design-system/src/tokens
|
||||
git commit -m "feat(design-system): define three-level tokens"
|
||||
```
|
||||
|
||||
### Task 4: MUI adapter и provider (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/design-system/src/theme/createMoexVibeTheme.ts`
|
||||
- Create: `packages/design-system/src/theme/MoexVibeThemeProvider.tsx`
|
||||
- Create: `packages/design-system/src/theme/mui.d.ts`
|
||||
- Create: `packages/design-system/src/theme/index.ts`
|
||||
- Test: `packages/design-system/src/theme/createMoexVibeTheme.test.ts`
|
||||
|
||||
- [ ] **Step 1: Написать failing theme contract tests**
|
||||
|
||||
```ts
|
||||
const theme = createMoexVibeTheme();
|
||||
expect(theme.cssVarPrefix).toBe('mv');
|
||||
expect(theme.colorSchemes.light.palette.primary.main).toBe(resolveValue('color.action.primary'));
|
||||
expect(theme.typography.body1.fontFamily).toContain('Inter');
|
||||
expect(theme.shape.borderRadius).toBe(resolveValue('radius.md'));
|
||||
expect(theme.components?.MuiButton?.defaultProps).toMatchObject({ disableElevation: true });
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Подтвердить RED**
|
||||
|
||||
Run: `npm run test:design-system -- createMoexVibeTheme.test.ts`
|
||||
|
||||
Expected: FAIL, factory отсутствует.
|
||||
|
||||
- [ ] **Step 3: Реализовать adapter**
|
||||
|
||||
Использовать `createTheme({ cssVariables: { cssVarPrefix: 'mv' }, colorSchemes: { light: ... } })`.
|
||||
Theme получает palette, typography, spacing, shape, shadows, transitions и component overrides только
|
||||
через `resolveValue`. `mui.d.ts` включает `themeCssVarsAugmentation` и добавляет `finance` palette roles.
|
||||
|
||||
- [ ] **Step 4: Реализовать provider**
|
||||
|
||||
`MoexVibeThemeProvider` оборачивает MUI `ThemeProvider` и `CssBaseline`, принимает только `children`;
|
||||
mode API в v1 не экспортируется.
|
||||
|
||||
- [ ] **Step 5: Проверить и commit**
|
||||
|
||||
Run: `npm run test:design-system -- createMoexVibeTheme.test.ts && npm run build:design-system`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
```bash
|
||||
git add packages/design-system/src/theme packages/design-system/src/tokens
|
||||
git commit -m "feat(design-system): add MUI theme adapter"
|
||||
```
|
||||
|
||||
### Task 5: Storybook и automated story checks
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/design-system/.storybook/main.ts`
|
||||
- Create: `packages/design-system/.storybook/preview.tsx`
|
||||
- Create: `packages/design-system/.storybook/vitest.setup.ts`
|
||||
- Modify: `packages/design-system/vitest.config.ts`
|
||||
- Modify: `packages/design-system/package.json`
|
||||
|
||||
- [ ] **Step 1: Настроить Storybook 10.2**
|
||||
|
||||
```ts
|
||||
const config: StorybookConfig = {
|
||||
framework: '@storybook/react-vite',
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
addons: ['@storybook/addon-a11y', '@storybook/addon-vitest'],
|
||||
};
|
||||
```
|
||||
|
||||
`preview.tsx` добавляет decorator `MoexVibeThemeProvider`, background `color.canvas` и параметры
|
||||
`a11y.test = 'error'`, layout `centered` по умолчанию.
|
||||
|
||||
- [ ] **Step 2: Добавить Storybook Vitest browser project**
|
||||
|
||||
Использовать `storybookTest`, `@vitest/browser-playwright` и headless Chromium; setup регистрирует
|
||||
`@storybook/addon-a11y/preview` и project annotations через `setProjectAnnotations`.
|
||||
|
||||
- [ ] **Step 3: Проверить стенд**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npx playwright install chromium
|
||||
npm run build:storybook
|
||||
```
|
||||
|
||||
Expected: static Storybook build PASS. Первый `test:storybook` запускается после появления stories в
|
||||
Task 6.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json packages/design-system
|
||||
git commit -m "build(design-system): configure Storybook workbench"
|
||||
```
|
||||
|
||||
### Task 6: Typography и actions (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/design-system/src/components/{Text,Heading,Link,Button,IconButton}/`
|
||||
- Test: colocated `*.test.tsx`
|
||||
- Stories: colocated `*.stories.tsx`
|
||||
|
||||
- [ ] **Step 1: Написать failing interaction/type tests**
|
||||
|
||||
Проверить semantic heading level, tone mapping, external link rel, loading button disabled state и
|
||||
progress label, обязательный accessible label IconButton. Type tests отклоняют `color`, произвольный
|
||||
`variant` и `sx`.
|
||||
|
||||
- [ ] **Step 2: Подтвердить RED**
|
||||
|
||||
Run: `npm run test:design-system -- Text Heading Link Button IconButton`
|
||||
|
||||
Expected: FAIL, exports отсутствуют.
|
||||
|
||||
- [ ] **Step 3: Реализовать компоненты по public contracts**
|
||||
|
||||
Каждый каталог содержит component, props и index. `Button loading` сохраняет ширину, блокирует повторный
|
||||
click и имеет `aria-busy`; `IconButton` всегда устанавливает `aria-label={label}`.
|
||||
|
||||
- [ ] **Step 4: Добавить stories и проверить GREEN**
|
||||
|
||||
Stories: все variants/sizes/tones, disabled, loading, long Russian label, keyboard focus reference.
|
||||
|
||||
Run: `npm run test:design-system && npm run test:storybook && npm run build:storybook`
|
||||
|
||||
Expected: PASS без a11y violations.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/design-system/src
|
||||
git commit -m "feat(design-system): add typography and actions"
|
||||
```
|
||||
|
||||
### Task 7: Inputs, surfaces и feedback (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/design-system/src/components/{TextField,Select,Checkbox,Surface,Card,Chip,Badge,Alert,Dialog,Skeleton,Progress}/`
|
||||
- Test/Stories: colocated `*.test.tsx`, `*.stories.tsx`
|
||||
|
||||
- [ ] **Step 1: Написать failing contract tests**
|
||||
|
||||
Проверить label/helper/error association, Select options и keyboard opening, Checkbox label click,
|
||||
Surface padding/elevation mapping, Dialog focus/escape/label, Alert role, determinate/indeterminate
|
||||
Progress accessible name и Skeleton `aria-hidden`.
|
||||
|
||||
- [ ] **Step 2: Подтвердить RED**
|
||||
|
||||
Run: `npm run test:design-system -- TextField Select Checkbox Surface Card Chip Badge Alert Dialog Skeleton Progress`
|
||||
|
||||
Expected: FAIL, components отсутствуют.
|
||||
|
||||
- [ ] **Step 3: Реализовать ограниченные wrappers**
|
||||
|
||||
`Select` принимает `{ value: string; label: string; disabled?: boolean }[]`; `Checkbox` требует `label`;
|
||||
`Dialog` требует `title` и управляется через `open/onClose`; Surface/Card не принимают raw elevation.
|
||||
|
||||
- [ ] **Step 4: Stories, GREEN и commit**
|
||||
|
||||
Stories покрывают default/focus/disabled/error/loading, длинный русский текст и narrow viewport.
|
||||
|
||||
Run: `npm run test:design-system && npm run test:storybook && npm run build:storybook`
|
||||
|
||||
Expected: PASS.
|
||||
|
||||
```bash
|
||||
git add packages/design-system/src
|
||||
git commit -m "feat(design-system): add controls and feedback"
|
||||
```
|
||||
|
||||
### Task 8: Финансовые data patterns (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/design-system/src/components/{DataTable,Metric,Money,PriceChange}/`
|
||||
- Test/Stories: colocated `*.test.tsx`, `*.stories.tsx`
|
||||
- Test: `packages/design-system/src/visual/financial-patterns.visual.test.ts`
|
||||
|
||||
- [ ] **Step 1: Написать failing tests**
|
||||
|
||||
Проверить table caption, semantic headers, sortable button `aria-sort`, balanced/compact density, loading
|
||||
и empty states; `Money` через `Intl.NumberFormat`; `PriceChange` знак, текстовое направление и не только
|
||||
цвет; `Metric` composition.
|
||||
|
||||
- [ ] **Step 2: Подтвердить RED**
|
||||
|
||||
Run: `npm run test:design-system -- DataTable Metric Money PriceChange`
|
||||
|
||||
Expected: FAIL.
|
||||
|
||||
- [ ] **Step 3: Реализовать patterns**
|
||||
|
||||
`DataTable` принимает готовый TanStack `Table<T>` и не владеет server pagination/sorting. Defaults:
|
||||
`density="balanced"`, locale `ru-RU`, currency `RUB`. Negative/positive output включает видимый знак и
|
||||
screen-reader label.
|
||||
|
||||
- [ ] **Step 4: Stories, visual baselines и commit**
|
||||
|
||||
Canonical screenshot cases: balanced table, compact table, positive/negative metrics, empty/loading.
|
||||
Browser test запускает portable stories через `composeStories`, затем делает
|
||||
`expect(page.getByTestId('visual-root')).toMatchScreenshot('<stable-name>.png')`; baselines коммитятся
|
||||
рядом с visual test. Обновление baseline допустимо только вместе с объяснением визуального изменения.
|
||||
|
||||
Run: `npm run test:design-system && npm run test:storybook`
|
||||
|
||||
Expected: interaction, a11y и screenshot checks PASS.
|
||||
|
||||
```bash
|
||||
git add packages/design-system/src
|
||||
git commit -m "feat(design-system): add financial data patterns"
|
||||
```
|
||||
|
||||
### Task 9: Form и page-state patterns (TDD)
|
||||
|
||||
**Files:**
|
||||
- Create: `packages/design-system/src/components/{FormField,FilterBar,EmptyState,ErrorState,LoadingState}/`
|
||||
- Test/Stories: colocated `*.test.tsx`, `*.stories.tsx`
|
||||
|
||||
- [ ] **Step 1: Написать failing tests**
|
||||
|
||||
Проверить `htmlFor`/description/error association FormField, wrapping and action placement FilterBar,
|
||||
semantic heading/action для Empty/Error, `aria-live="polite"` LoadingState и reduced-motion rendering.
|
||||
|
||||
- [ ] **Step 2: RED, implementation, GREEN**
|
||||
|
||||
Run before: `npm run test:design-system -- FormField FilterBar EmptyState ErrorState LoadingState`
|
||||
|
||||
Expected before: FAIL. Реализовать только layout/semantics без API/domain knowledge.
|
||||
|
||||
Run after: `npm run test:design-system && npm run test:storybook`
|
||||
|
||||
Expected after: PASS.
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add packages/design-system/src
|
||||
git commit -m "feat(design-system): add form and page-state patterns"
|
||||
```
|
||||
|
||||
### Task 10: Frontend integration и MUI boundary
|
||||
|
||||
**Files:**
|
||||
- Modify: `apps/frontend/package.json`
|
||||
- Modify: `apps/frontend/tsconfig.json`
|
||||
- Modify: `apps/frontend/vite.config.ts`
|
||||
- Modify: `apps/frontend/src/app/providers/AppProviders.tsx`
|
||||
- Modify: `apps/frontend/.eslintrc.cjs`
|
||||
- Delete: `apps/frontend/src/app/styles/theme.ts`
|
||||
- Test: `apps/frontend/src/app/providers/AppProviders.test.tsx`
|
||||
|
||||
- [ ] **Step 1: Написать failing provider test**
|
||||
|
||||
Render `AppProviders`, assert generated `--mv-palette-primary-main` exists and Query/Session providers
|
||||
по-прежнему доступны. Test не проверяет редизайн страниц.
|
||||
|
||||
- [ ] **Step 2: Подключить workspace package**
|
||||
|
||||
Добавить dependency, `@fontsource/inter` и aliases к source для dev/test; заменить MUI
|
||||
ThemeProvider/CssBaseline на `MoexVibeThemeProvider`. Удалить старый локальный theme и импортировать
|
||||
Inter 400/500/600/700 в provider entry ровно один раз.
|
||||
|
||||
- [ ] **Step 3: Зафиксировать ESLint allowlist**
|
||||
|
||||
Во frontend запретить root import `@mui/material` и subpaths через `no-restricted-imports`; исключить
|
||||
только `@mui/material/Box`, `Stack`, `Grid`. Сам пакет design-system использует отдельный lint config.
|
||||
|
||||
- [ ] **Step 4: Проверить отсутствие визуальной миграции**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
npm run test:frontend
|
||||
npm run lint -w apps/frontend
|
||||
npm run build:design-system
|
||||
npm run build:frontend
|
||||
```
|
||||
|
||||
Expected: PASS; legacy `styles.css` и product component markup не изменены.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/frontend packages/design-system package.json package-lock.json
|
||||
git commit -m "feat(frontend): connect design system foundation"
|
||||
```
|
||||
|
||||
### Task 11: Docusaurus и ADR
|
||||
|
||||
**Files:**
|
||||
- Create: `apps/docs/docs/design-system/{overview,foundations,tokens,components,patterns,accessibility,governance}.md`
|
||||
- Create: `apps/docs/docs/adr/ADR-016-design-system.md`
|
||||
- Create: `packages/design-system/CHANGELOG.md`
|
||||
- Modify: `apps/docs/sidebars.ts`
|
||||
- Modify: `apps/docs/docs/adr/index.md`
|
||||
- Modify: `apps/docs/docs/frontend/styling.md`
|
||||
|
||||
- [ ] **Step 1: Написать канонические правила**
|
||||
|
||||
Документы фиксируют light-only v1, balanced density, три token levels, MUI allowlist, каталог,
|
||||
component-card template, WCAG 2.2 AA и promotion/change process. Components page содержит матрицу
|
||||
«задача → компонент → не использовать» и полную карточку обязательного шаблона для каждого public
|
||||
export. `CHANGELOG.md` начинается с `0.1.0` и документирует public API foundation; governance описывает
|
||||
обязательную migration note для будущих breaking changes.
|
||||
|
||||
- [ ] **Step 2: Добавить ADR-016**
|
||||
|
||||
ADR содержит Context, варианты theme-only/hybrid/full-wrapper, решение hybrid, последствия, Storybook
|
||||
роль и будущие DTCG/Android adapters.
|
||||
|
||||
- [ ] **Step 3: Обновить sidebar и проверить docs**
|
||||
|
||||
Run: `npm run build:docs`
|
||||
|
||||
Expected: PASS без broken links; новый раздел виден как отдельная категория «Дизайн-система».
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add apps/docs packages/design-system/CHANGELOG.md
|
||||
git commit -m "docs(design-system): publish usage guidelines"
|
||||
```
|
||||
|
||||
### Task 12: CI, visual checks и финальная верификация
|
||||
|
||||
**Files:**
|
||||
- Modify: `.gitea/workflows/ci.yml`
|
||||
- Modify: `README.md`
|
||||
- Modify: `docs/features/design-system-foundation/tasks.md`
|
||||
|
||||
- [ ] **Step 1: Добавить CI gates**
|
||||
|
||||
После `npm ci` установить Chromium `npx playwright install --with-deps chromium`; добавить design-system
|
||||
unit/story tests, Storybook build, design-system build и docs build. Сохранять Storybook static build и
|
||||
visual diff через `actions/upload-artifact@v4` с `if: failure()`.
|
||||
|
||||
- [ ] **Step 2: Обновить команды README**
|
||||
|
||||
Добавить `npm run storybook`, `build:storybook`, `test:design-system`, `test:storybook`,
|
||||
`build:design-system` и пояснить, что Docusaurus — published docs, Storybook — engineering workbench.
|
||||
|
||||
- [ ] **Step 3: Запустить полный DoD**
|
||||
|
||||
```bash
|
||||
npm run format:check
|
||||
npm run lint
|
||||
npm run test:backend
|
||||
npm run test:frontend
|
||||
npm run test:design-system
|
||||
npm run test:storybook
|
||||
npm run build:backend
|
||||
npm run build:design-system
|
||||
npm run build:storybook
|
||||
npm run build:frontend
|
||||
npm run build:docs
|
||||
git diff --check
|
||||
git status --short
|
||||
```
|
||||
|
||||
Expected: все команды PASS; status содержит только намеренные изменения до финального commit.
|
||||
|
||||
- [ ] **Step 4: Обновить SDD tasks и запросить code review**
|
||||
|
||||
Отметить выполненные checkbox в `tasks.md`, применить `superpowers:requesting-code-review`, исправить
|
||||
только подтверждённые scope issues и повторить полный DoD.
|
||||
|
||||
- [ ] **Step 5: Final commit**
|
||||
|
||||
```bash
|
||||
git add .gitea/workflows/ci.yml README.md docs/features/design-system-foundation
|
||||
git commit -m "ci(design-system): enforce foundation quality gates"
|
||||
```
|
||||
168
docs/features/design-system-foundation/spec.md
Normal file
168
docs/features/design-system-foundation/spec.md
Normal file
@ -0,0 +1,168 @@
|
||||
# Design System Foundation
|
||||
|
||||
## Статус
|
||||
|
||||
Approved — утверждена пользователем 2026-06-21.
|
||||
|
||||
## Цель
|
||||
|
||||
Создать внутреннюю дизайн-систему MoexVibe поверх MUI, которая задаёт единый визуальный язык,
|
||||
контролируемый набор UI-компонентов и правила их применения. Первая версия должна обслуживать web-
|
||||
приложение MoexVibe и сохранять нейтральную к платформе модель токенов для будущих адаптеров,
|
||||
включая Android.
|
||||
|
||||
## Принципы
|
||||
|
||||
- MUI является компонентным и accessibility-движком, но не определяет визуальную идентичность
|
||||
MoexVibe.
|
||||
- Визуальное направление: спокойный нейтральный финансовый интерфейс со сбалансированной плотностью.
|
||||
- Текущие стили приложения не являются источником дизайн-решений. Их миграция выполняется после
|
||||
foundation отдельными фичами.
|
||||
- Дизайн-система не содержит бизнес-логику, API-клиенты, маршрутизацию и доменные компоненты.
|
||||
- Docusaurus остаётся единственной опубликованной человекочитаемой документацией проекта.
|
||||
|
||||
## Артефакты
|
||||
|
||||
### Workspace-пакет
|
||||
|
||||
В monorepo должен появиться внутренний пакет `@moex-vibe/design-system` со следующими границами:
|
||||
|
||||
- platform-neutral токены без зависимостей от React и MUI;
|
||||
- MUI-адаптер, создающий тему и CSS variables из токенов;
|
||||
- Core UI и повторяемые product patterns;
|
||||
- стабильные subpath exports для токенов, темы и компонентов.
|
||||
|
||||
Продуктовый frontend использует компоненты дизайн-системы. Прямые импорты MUI разрешены только для
|
||||
компоновки (`Box`, `Stack`, `Grid` и системные layout utilities). Интерактивные и визуально значимые
|
||||
компоненты должны поступать из `@moex-vibe/design-system`.
|
||||
|
||||
### Storybook
|
||||
|
||||
Storybook является локальным и CI-стендом дизайн-системы. Он показывает варианты, состояния,
|
||||
адаптивное поведение и accessibility-контракты компонентов, но не считается опубликованной
|
||||
документацией для пользователей проекта.
|
||||
|
||||
### Docusaurus
|
||||
|
||||
В `apps/docs` должен появиться раздел «Дизайн-система» со страницами:
|
||||
|
||||
- обзор и принципы;
|
||||
- foundations: цвет, типографика, spacing, размеры, радиусы, elevation и motion;
|
||||
- модель токенов и правила их изменения;
|
||||
- каталог компонентов и product patterns;
|
||||
- accessibility;
|
||||
- contribution и governance.
|
||||
|
||||
Каждая карточка компонента описывает: назначение и запреты, anatomy, разрешённые варианты, состояния,
|
||||
keyboard behavior, accessibility, content rules, responsive behavior, корректные примеры и
|
||||
антипаттерны.
|
||||
|
||||
## Модель токенов
|
||||
|
||||
Source of truth первой версии — типизированные TypeScript-объекты. Их схема и имена не должны
|
||||
зависеть от MUI, React или CSS. Формат должен оставаться JSON-совместимым, чтобы позднее добавить
|
||||
экспорт в DTCG/Android resources без изменения семантических имён.
|
||||
|
||||
Токены состоят из трёх уровней с однонаправленными зависимостями:
|
||||
|
||||
1. **Primitive/reference** — сырые палитры, шкалы spacing и размеров, типографика, радиусы, тени и
|
||||
motion. Только на этом уровне допустимы абсолютные визуальные значения.
|
||||
2. **Semantic/system** — роли: canvas, surface, text, border, action, feedback и финансовая семантика
|
||||
positive/negative/neutral. Будущая тёмная тема переопределяет этот уровень, не меняя компоненты.
|
||||
3. **Component** — устойчивые решения конкретных компонентов и вариантов. Они ссылаются на semantic
|
||||
tokens и создаются только для повторяемых или тематизируемых решений, а не для каждого CSS-
|
||||
свойства MUI.
|
||||
|
||||
Приложение не должно использовать primitive tokens или произвольные визуальные значения напрямую.
|
||||
Цвет не может быть единственным способом сообщить финансовое состояние.
|
||||
|
||||
## Тема и визуальные основы
|
||||
|
||||
- Первая версия включает только светлую тему. API темы должен допускать добавление новых color schemes
|
||||
без изменения API компонентов.
|
||||
- MUI theme использует CSS variables и получает palette, typography, spacing, shape, elevation, motion,
|
||||
defaults, variants и style overrides из токенов.
|
||||
- Основная плотность интерфейса — сбалансированная. Компактный вариант допускается как явный вариант
|
||||
data-heavy компонентов, но не как глобальный режим v1.
|
||||
- Все состояния focus-visible должны быть заметны; компоненты поддерживают keyboard navigation и
|
||||
`prefers-reduced-motion` там, где используется анимация.
|
||||
- Целевой уровень доступности — WCAG 2.2 AA для применимых web-компонентов.
|
||||
|
||||
## Компонентная модель
|
||||
|
||||
### Core UI v1
|
||||
|
||||
- Typography: `Text`, `Heading`, `Link`.
|
||||
- Actions: `Button`, `IconButton`.
|
||||
- Inputs: `TextField`, `Select`, `Checkbox`.
|
||||
- Surfaces and labels: `Surface`, `Card`, `Chip`, `Badge`.
|
||||
- Feedback: `Alert`, `Dialog`, `Skeleton`, `Progress`.
|
||||
|
||||
Core UI ограничивает разрешённые variants и состояния MUI, сохраняет доступность и не содержит
|
||||
доменной логики.
|
||||
|
||||
### Product patterns v1
|
||||
|
||||
- Data display: `DataTable`, `Metric`, `Money`, `PriceChange`.
|
||||
- Forms and filtering: `FormField`, `FilterBar`.
|
||||
- Page states: `EmptyState`, `ErrorState`, `LoadingState`.
|
||||
|
||||
Product pattern попадает в пакет, если он не знает бизнес-домен и имеет минимум два подтверждённых
|
||||
места использования. Исключение — базовые интерактивные Core UI primitives, необходимые для
|
||||
целостного API.
|
||||
|
||||
Доменные компоненты, включая broker-, portfolio- и screener-specific композиции, остаются в
|
||||
соответствующих FSD-слоях frontend.
|
||||
|
||||
## Governance
|
||||
|
||||
- Изменение значения primitive token не должно молча менять смысл semantic token.
|
||||
- Новый token требует описания роли и проверки существующих эквивалентов.
|
||||
- Новый variant или компонент требует documented use case; API «на всякий случай» не добавляется.
|
||||
- Breaking changes внутреннего пакета фиксируются в changelog и миграционной заметке, даже пока пакет
|
||||
не публикуется во внешний npm registry.
|
||||
- Архитектура пакета и граница прямого использования MUI фиксируются отдельным ADR.
|
||||
|
||||
## Проверки качества
|
||||
|
||||
- Unit-тесты проверяют token contracts, уникальность имён, допустимые ссылки и отсутствие циклов.
|
||||
- Type tests проверяют публичные exports и разрешённые component variants.
|
||||
- Interaction tests проверяют keyboard и пользовательские состояния интерактивных компонентов.
|
||||
- Storybook содержит stories для default, hover/focus reference, disabled, loading, error и responsive
|
||||
состояний, когда они применимы.
|
||||
- Автоматические accessibility-проверки выполняются для stories и не допускают серьёзных нарушений.
|
||||
- Visual regression покрывает репрезентативные состояния Core UI и product patterns.
|
||||
- CI собирает workspace-пакет, Storybook, frontend и Docusaurus; затронутые lint и tests проходят.
|
||||
|
||||
## Вне scope
|
||||
|
||||
- Тёмная тема и UI-переключатель темы.
|
||||
- Android-адаптер, DTCG/JSON export и синхронизация с Figma.
|
||||
- Публикация пакета во внешний npm registry.
|
||||
- Backend-driven UI.
|
||||
- Массовая миграция существующих страниц и удаление legacy CSS.
|
||||
- Редизайн доменных экранов, графиков и визуализаций данных.
|
||||
|
||||
## Последовательность внедрения
|
||||
|
||||
1. Эта фича создаёт Design System Foundation и не переписывает продуктовые экраны.
|
||||
2. Отдельная фича мигрирует один репрезентативный pilot-экран и возвращает подтверждённые изменения в
|
||||
tokens/component API.
|
||||
3. Остальные области мигрируются отдельными вертикальными срезами; legacy styles удаляются только
|
||||
после миграции всех их потребителей.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- [ ] `@moex-vibe/design-system` подключён как внутренний workspace-пакет с документированными public
|
||||
exports.
|
||||
- [ ] Реализованы три уровня platform-neutral TypeScript tokens и автоматические проверки их
|
||||
контрактов.
|
||||
- [ ] Light MUI theme полностью строится из токенов и предоставляет CSS variables.
|
||||
- [ ] Реализован и задокументирован каталог Core UI v1 и product patterns v1.
|
||||
- [ ] Прямое использование MUI ограничено documented allowlist для layout utilities.
|
||||
- [ ] Storybook локально запускается, собирается в CI и содержит обязательные состояния компонентов.
|
||||
- [ ] Accessibility и visual regression checks проходят для согласованного набора stories.
|
||||
- [ ] В Docusaurus опубликован полный раздел дизайн-системы и правила выбора компонентов.
|
||||
- [ ] Создан ADR о границах пакета, MUI-адаптере и будущих platform adapters.
|
||||
- [ ] Frontend, docs и design-system package проходят build, lint и tests.
|
||||
- [ ] Существующие продуктовые страницы визуально не мигрированы в рамках этой фичи.
|
||||
68
docs/features/design-system-foundation/tasks.md
Normal file
68
docs/features/design-system-foundation/tasks.md
Normal file
@ -0,0 +1,68 @@
|
||||
# Design System Foundation — Tasks
|
||||
|
||||
Исполнять по `plan.md` последовательно, используя TDD и отмечая checkbox только после успешной
|
||||
проверки соответствующего шага.
|
||||
|
||||
## 1. Pre-flight и workspace
|
||||
|
||||
- [ ] Подтвердить ветку, чистый worktree и зелёные baseline tests/lint/build.
|
||||
- [ ] Добавить `packages/design-system` в npm workspaces и root scripts.
|
||||
- [ ] Создать package manifest, TypeScript/Vitest/ESLint configs и test setup.
|
||||
- [ ] Установить зависимости; проверить пустой package build/test/lint.
|
||||
- [ ] Закоммитить workspace shell.
|
||||
|
||||
## 2. Tokens
|
||||
|
||||
- [ ] Написать RED-тесты alias resolver: chain, missing target, cycle, type mismatch.
|
||||
- [ ] Реализовать token schema и resolver; получить GREEN.
|
||||
- [ ] Написать RED-тесты целостности трёх token levels.
|
||||
- [ ] Добавить primitive/reference tokens.
|
||||
- [ ] Добавить light semantic tokens только через aliases.
|
||||
- [ ] Добавить component tokens только через semantic aliases.
|
||||
- [ ] Проверить guards, build и отсутствие hardcoded values вне primitives.
|
||||
- [ ] Закоммитить token foundation.
|
||||
|
||||
## 3. MUI adapter и Storybook
|
||||
|
||||
- [ ] Написать RED-тесты MUI theme contract.
|
||||
- [ ] Реализовать `createMoexVibeTheme`, type augmentation и provider; получить GREEN.
|
||||
- [ ] Настроить Storybook React/Vite с общим theme decorator.
|
||||
- [ ] Подключить addon-a11y и Vitest browser project с Playwright Chromium.
|
||||
- [ ] Проверить package и static Storybook build.
|
||||
- [ ] Закоммитить theme adapter и Storybook workbench.
|
||||
|
||||
## 4. Core UI
|
||||
|
||||
- [ ] Реализовать через TDD `Text`, `Heading`, `Link`, `Button`, `IconButton`.
|
||||
- [ ] Добавить их stories, type checks и a11y checks.
|
||||
- [ ] Реализовать через TDD `TextField`, `Select`, `Checkbox`.
|
||||
- [ ] Реализовать через TDD `Surface`, `Card`, `Chip`, `Badge`.
|
||||
- [ ] Реализовать через TDD `Alert`, `Dialog`, `Skeleton`, `Progress`.
|
||||
- [ ] Добавить обязательные states, responsive stories и visual baselines.
|
||||
- [ ] Проверить unit/story/a11y/build gates и закоммитить Core UI.
|
||||
|
||||
## 5. Product patterns
|
||||
|
||||
- [ ] Реализовать через TDD `DataTable`, включая caption, sorting semantics и density variants.
|
||||
- [ ] Реализовать через TDD `Metric`, `Money`, `PriceChange` без color-only semantics.
|
||||
- [ ] Добавить financial stories и стабильные screenshot baselines.
|
||||
- [ ] Реализовать через TDD `FormField` и `FilterBar`.
|
||||
- [ ] Реализовать через TDD `EmptyState`, `ErrorState`, `LoadingState`.
|
||||
- [ ] Проверить unit/story/a11y/visual gates и закоммитить patterns.
|
||||
|
||||
## 6. Интеграция и governance
|
||||
|
||||
- [ ] Написать RED-тест frontend provider integration.
|
||||
- [ ] Подключить workspace package и Inter во frontend shell; удалить локальную MUI theme.
|
||||
- [ ] Enforce ESLint allowlist для `Box`, `Stack`, `Grid`.
|
||||
- [ ] Подтвердить, что product pages и legacy CSS не мигрированы.
|
||||
- [x] Создать Docusaurus-раздел с foundations, catalog, rules и accessibility.
|
||||
- [x] Создать ADR-016 и changelog `0.1.0`.
|
||||
- [x] Обновить sidebar, styling docs и README; собрать docs.
|
||||
|
||||
## 7. CI и завершение
|
||||
|
||||
- [x] Добавить design-system, Storybook, Playwright, visual и docs gates в CI.
|
||||
- [x] Запустить полный DoD из Task 12 `plan.md`.
|
||||
- [ ] Запросить code review и устранить подтверждённые scope issues.
|
||||
- [ ] Повторить полный DoD, отметить все tasks и сделать финальный commit.
|
||||
3935
package-lock.json
generated
3935
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
15
package.json
15
package.json
@ -4,7 +4,8 @@
|
||||
"workspaces": [
|
||||
"apps/backend",
|
||||
"apps/frontend",
|
||||
"apps/docs"
|
||||
"apps/docs",
|
||||
"packages/design-system"
|
||||
],
|
||||
"scripts": {
|
||||
"dev:backend": "npm run start:dev -w apps/backend",
|
||||
@ -18,17 +19,29 @@
|
||||
"format:check": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"dev:docs": "npm run dev -w apps/docs",
|
||||
"build:docs": "npm run build -w apps/docs",
|
||||
"build:design-system": "npm run build -w packages/design-system",
|
||||
"test:design-system": "npm run test -w packages/design-system",
|
||||
"lint:design-system": "npm run lint -w packages/design-system",
|
||||
"storybook": "npm run storybook -w packages/design-system",
|
||||
"build:storybook": "npm run build-storybook -w packages/design-system",
|
||||
"test:storybook": "npm run test:storybook -w packages/design-system",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"lint-staged": {
|
||||
"apps/backend/src/**/*.ts": ["eslint --max-warnings=0"],
|
||||
"apps/backend/test/**/*.ts": ["eslint --max-warnings=0"],
|
||||
"apps/frontend/src/**/*.{ts,tsx}": ["eslint --max-warnings=0"],
|
||||
"packages/design-system/src/**/*.{ts,tsx}": ["eslint --max-warnings=0"],
|
||||
"**/*.{ts,tsx}": ["prettier --check"]
|
||||
},
|
||||
"devDependencies": {
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.4.0",
|
||||
"prettier": "^3.0.0"
|
||||
},
|
||||
"overrides": {
|
||||
"@storybook/test": {
|
||||
"storybook": "10.4.6"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
28
packages/design-system/.eslintrc.cjs
Normal file
28
packages/design-system/.eslintrc.cjs
Normal file
@ -0,0 +1,28 @@
|
||||
module.exports = {
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
sourceType: 'module',
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks'],
|
||||
extends: [
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
root: true,
|
||||
env: {
|
||||
browser: true,
|
||||
es2020: true,
|
||||
node: true,
|
||||
},
|
||||
settings: {
|
||||
react: { version: 'detect' },
|
||||
},
|
||||
ignorePatterns: ['.eslintrc.cjs', 'vitest.config.ts', 'dist/', '.storybook/'],
|
||||
rules: {
|
||||
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||
'@typescript-eslint/no-explicit-any': 'off',
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
},
|
||||
};
|
||||
1
packages/design-system/.gitignore
vendored
Normal file
1
packages/design-system/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
storybook-static/
|
||||
9
packages/design-system/.storybook/main.ts
Normal file
9
packages/design-system/.storybook/main.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import type { StorybookConfig } from '@storybook/react-vite';
|
||||
|
||||
const config: StorybookConfig = {
|
||||
framework: '@storybook/react-vite',
|
||||
stories: ['../src/**/*.stories.@(ts|tsx)'],
|
||||
addons: ['@storybook/addon-a11y', '@storybook/addon-vitest'],
|
||||
};
|
||||
|
||||
export default config;
|
||||
24
packages/design-system/.storybook/preview.tsx
Normal file
24
packages/design-system/.storybook/preview.tsx
Normal file
@ -0,0 +1,24 @@
|
||||
import type { Preview } from '@storybook/react';
|
||||
import { MoexVibeThemeProvider } from '../src/theme';
|
||||
|
||||
const preview: Preview = {
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<MoexVibeThemeProvider>
|
||||
<Story />
|
||||
</MoexVibeThemeProvider>
|
||||
),
|
||||
],
|
||||
parameters: {
|
||||
a11y: {
|
||||
test: 'error',
|
||||
},
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
default: 'canvas',
|
||||
values: [{ name: 'canvas', value: '#f5f5f5' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default preview;
|
||||
5
packages/design-system/.storybook/vitest.setup.ts
Normal file
5
packages/design-system/.storybook/vitest.setup.ts
Normal file
@ -0,0 +1,5 @@
|
||||
import '@storybook/addon-a11y/preview';
|
||||
import { setProjectAnnotations } from '@storybook/react';
|
||||
import preview from './preview';
|
||||
|
||||
setProjectAnnotations(preview);
|
||||
11
packages/design-system/CHANGELOG.md
Normal file
11
packages/design-system/CHANGELOG.md
Normal file
@ -0,0 +1,11 @@
|
||||
# Changelog
|
||||
|
||||
## 0.1.0 (2026-06-21)
|
||||
|
||||
### Added
|
||||
|
||||
- Token system: primitives, semantic (light), and component tokens with DTCG-like resolver
|
||||
- Theme adapter: MUI 6.5 theme with CSS variables (`--mv-*`)
|
||||
- Components: 20+ production-ready wrappers (Typography, Actions, Inputs, Surfaces, Feedback, Financial Data, Form, Page States)
|
||||
- Storybook: interactive catalog with a11y checks
|
||||
- First stable public API
|
||||
52
packages/design-system/package.json
Normal file
52
packages/design-system/package.json
Normal file
@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@moex-vibe/design-system",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"files": ["dist"],
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
"./tokens": "./dist/tokens/index.js",
|
||||
"./theme": "./dist/theme/index.js"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"test": "vitest run --project unit",
|
||||
"storybook": "storybook dev -p 6006 --no-open",
|
||||
"build-storybook": "storybook build",
|
||||
"test:storybook": "vitest run --project storybook",
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\""
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emotion/react": "^11.14.0",
|
||||
"@emotion/styled": "^11.14.1",
|
||||
"@mui/icons-material": "^6.5.0",
|
||||
"@mui/material": "^6.5.0",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"react": "^18.3.0",
|
||||
"react-dom": "^18.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@storybook/addon-a11y": "^10.2.9",
|
||||
"@storybook/addon-vitest": "^10.2.9",
|
||||
"@storybook/react-vite": "^10.2.9",
|
||||
"@storybook/test": "^8.6.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"@vitest/browser": "^4.1.8",
|
||||
"@vitest/browser-playwright": "^4.1.8",
|
||||
"eslint": "^8.0.0",
|
||||
"eslint-plugin-react": "^7.34.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.0",
|
||||
"playwright": "^1.55.0",
|
||||
"storybook": "^10.2.9",
|
||||
"typescript": "^5.3.0",
|
||||
"vitest": "^4.1.8"
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,69 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from '../Button/Button';
|
||||
import { Alert } from './Alert';
|
||||
|
||||
const meta: Meta<typeof Alert> = {
|
||||
title: 'Feedback/Alert',
|
||||
component: Alert,
|
||||
argTypes: {
|
||||
severity: {
|
||||
control: 'select',
|
||||
options: ['info', 'success', 'warning', 'error'],
|
||||
},
|
||||
title: { control: 'text' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Alert>;
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
severity: 'info',
|
||||
title: 'Информация',
|
||||
children: 'Данные обновляются каждые 15 минут',
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
severity: 'success',
|
||||
title: 'Успешно',
|
||||
children: 'Портфель успешно создан',
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
severity: 'warning',
|
||||
title: 'Внимание',
|
||||
children: 'Цена акции превысила лимит',
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
severity: 'error',
|
||||
title: 'Ошибка',
|
||||
children: 'Не удалось загрузить данные',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAction: Story = {
|
||||
args: {
|
||||
severity: 'warning',
|
||||
children: 'Обновите страницу для получения актуальных данных',
|
||||
action: (
|
||||
<Button size="small" variant="secondary">
|
||||
Обновить
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const WithoutTitle: Story = {
|
||||
args: {
|
||||
severity: 'info',
|
||||
children: 'Просто информационное сообщение без заголовка',
|
||||
},
|
||||
};
|
||||
54
packages/design-system/src/components/Alert/Alert.test.tsx
Normal file
54
packages/design-system/src/components/Alert/Alert.test.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Alert } from './Alert';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Alert', () => {
|
||||
it('has role alert', () => {
|
||||
renderWithTheme(<Alert severity="info">Message</Alert>);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders severity mapping for info', () => {
|
||||
renderWithTheme(<Alert severity="info">Info message</Alert>);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders severity mapping for success', () => {
|
||||
renderWithTheme(<Alert severity="success">Success message</Alert>);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders severity mapping for warning', () => {
|
||||
renderWithTheme(<Alert severity="warning">Warning message</Alert>);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders severity mapping for error', () => {
|
||||
renderWithTheme(<Alert severity="error">Error message</Alert>);
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders title', () => {
|
||||
renderWithTheme(
|
||||
<Alert severity="error" title="Error">
|
||||
Something went wrong
|
||||
</Alert>,
|
||||
);
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders action', () => {
|
||||
renderWithTheme(
|
||||
<Alert severity="warning" action={<button>Retry</button>}>
|
||||
Failed to load
|
||||
</Alert>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
20
packages/design-system/src/components/Alert/Alert.tsx
Normal file
20
packages/design-system/src/components/Alert/Alert.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { Alert as MuiAlert, type AlertProps as MuiAlertProps } from '@mui/material';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Severity = 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
export interface AlertProps extends Omit<MuiAlertProps, 'severity' | 'variant' | 'color' | 'sx'> {
|
||||
severity: Severity;
|
||||
title?: string;
|
||||
children?: ReactNode;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function Alert({ severity, title, children, action, ...props }: AlertProps) {
|
||||
return (
|
||||
<MuiAlert severity={severity} variant="filled" action={action} role="alert" {...props}>
|
||||
{title && <div>{title}</div>}
|
||||
{children}
|
||||
</MuiAlert>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/Alert/index.ts
Normal file
2
packages/design-system/src/components/Alert/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Alert } from './Alert';
|
||||
export type { AlertProps } from './Alert';
|
||||
@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import MailIcon from '@mui/icons-material/Mail';
|
||||
import { Badge } from './Badge';
|
||||
|
||||
const meta: Meta<typeof Badge> = {
|
||||
title: 'Surfaces/Badge',
|
||||
component: Badge,
|
||||
argTypes: {
|
||||
value: { control: 'number' },
|
||||
max: { control: 'number' },
|
||||
label: { control: 'text' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Badge>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
value: 5,
|
||||
label: 'Уведомления',
|
||||
children: <MailIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Overflow: Story = {
|
||||
args: {
|
||||
value: 150,
|
||||
max: 99,
|
||||
label: 'Уведомления',
|
||||
children: <MailIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Zero: Story = {
|
||||
args: {
|
||||
value: 0,
|
||||
label: 'Уведомления',
|
||||
children: <MailIcon />,
|
||||
},
|
||||
};
|
||||
31
packages/design-system/src/components/Badge/Badge.test.tsx
Normal file
31
packages/design-system/src/components/Badge/Badge.test.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Badge } from './Badge';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Badge', () => {
|
||||
it('displays value', () => {
|
||||
renderWithTheme(<Badge value={5} label="Notifications" />);
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows max overflow', () => {
|
||||
renderWithTheme(<Badge value={150} max={99} label="Notifications" />);
|
||||
expect(screen.getByText('99+')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has accessible label', () => {
|
||||
renderWithTheme(<Badge value={3} label="Notifications" />);
|
||||
const badge = screen.getByLabelText('Notifications');
|
||||
expect(badge).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render badge without label', () => {
|
||||
const { container } = renderWithTheme(<Badge value={3} label="" />);
|
||||
expect(container.querySelector('.MuiBadge-badge')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
17
packages/design-system/src/components/Badge/Badge.tsx
Normal file
17
packages/design-system/src/components/Badge/Badge.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import { Badge as MuiBadge } from '@mui/material';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface BadgeProps {
|
||||
value: number;
|
||||
max?: number;
|
||||
label: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function Badge({ value, max = 99, label, children }: BadgeProps) {
|
||||
return (
|
||||
<MuiBadge badgeContent={value} max={max} aria-label={label}>
|
||||
{children}
|
||||
</MuiBadge>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/Badge/index.ts
Normal file
2
packages/design-system/src/components/Badge/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Badge } from './Badge';
|
||||
export type { BadgeProps } from './Badge';
|
||||
105
packages/design-system/src/components/Button/Button.stories.tsx
Normal file
105
packages/design-system/src/components/Button/Button.stories.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from './Button';
|
||||
|
||||
const meta: Meta<typeof Button> = {
|
||||
title: 'Actions/Button',
|
||||
component: Button,
|
||||
argTypes: {
|
||||
variant: {
|
||||
control: 'select',
|
||||
options: ['primary', 'secondary', 'tertiary', 'danger'],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['small', 'medium'],
|
||||
},
|
||||
loading: {
|
||||
control: 'boolean',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Button>;
|
||||
|
||||
export const Primary: Story = {
|
||||
args: {
|
||||
variant: 'primary',
|
||||
children: 'Купить',
|
||||
},
|
||||
};
|
||||
|
||||
export const Secondary: Story = {
|
||||
args: {
|
||||
variant: 'secondary',
|
||||
children: 'Отмена',
|
||||
},
|
||||
};
|
||||
|
||||
export const Tertiary: Story = {
|
||||
args: {
|
||||
variant: 'tertiary',
|
||||
children: 'Подробнее',
|
||||
},
|
||||
};
|
||||
|
||||
export const Danger: Story = {
|
||||
args: {
|
||||
variant: 'danger',
|
||||
children: 'Удалить портфель',
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: 'small',
|
||||
children: 'Применить',
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
args: {
|
||||
loading: true,
|
||||
children: 'Отправка...',
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
children: 'Недоступно',
|
||||
},
|
||||
};
|
||||
|
||||
export const AllVariants: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||
<Button variant="primary">Купить</Button>
|
||||
<Button variant="secondary">Отмена</Button>
|
||||
<Button variant="tertiary">Подробнее</Button>
|
||||
<Button variant="danger">Удалить</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Button size="small">Маленькая</Button>
|
||||
<Button size="medium">Средняя</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const AllStates: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Button>Обычная</Button>
|
||||
<Button loading>Загрузка</Button>
|
||||
<Button disabled>Блокирована</Button>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
105
packages/design-system/src/components/Button/Button.test.tsx
Normal file
105
packages/design-system/src/components/Button/Button.test.tsx
Normal file
@ -0,0 +1,105 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Button } from './Button';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Button', () => {
|
||||
it('renders children', () => {
|
||||
renderWithTheme(<Button>Click me</Button>);
|
||||
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as a button element by default', () => {
|
||||
renderWithTheme(<Button>Click</Button>);
|
||||
expect(screen.getByRole('button', { name: /click/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders primary variant by default', () => {
|
||||
renderWithTheme(<Button>Click</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.classList.contains('MuiButton-contained')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders secondary variant', () => {
|
||||
renderWithTheme(<Button variant="secondary">Click</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.classList.contains('MuiButton-outlined')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders tertiary variant', () => {
|
||||
renderWithTheme(<Button variant="tertiary">Click</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.classList.contains('MuiButton-text')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders danger variant with contained style', () => {
|
||||
renderWithTheme(<Button variant="danger">Delete</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.classList.contains('MuiButton-contained')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not pass color prop to DOM', () => {
|
||||
renderWithTheme(<Button>Click</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn).not.toHaveAttribute('color');
|
||||
});
|
||||
|
||||
it('accepts className and style props', () => {
|
||||
renderWithTheme(
|
||||
<Button className="custom" style={{ margin: 4 }}>
|
||||
Click
|
||||
</Button>,
|
||||
);
|
||||
const btn = screen.getByText('Click');
|
||||
expect(btn.classList.contains('custom')).toBe(true);
|
||||
});
|
||||
|
||||
it('calls onClick when clicked', async () => {
|
||||
const handleClick = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithTheme(<Button onClick={handleClick}>Click</Button>);
|
||||
await user.click(screen.getByRole('button'));
|
||||
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows aria-busy when loading', () => {
|
||||
renderWithTheme(<Button loading>Save</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn).toHaveAttribute('aria-busy', 'true');
|
||||
});
|
||||
|
||||
it('disables button when loading', () => {
|
||||
renderWithTheme(<Button loading>Save</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn).toBeDisabled();
|
||||
});
|
||||
|
||||
it('does not call onClick when loading', () => {
|
||||
const handleClick = vi.fn();
|
||||
const { container } = renderWithTheme(
|
||||
<Button loading onClick={handleClick}>
|
||||
Save
|
||||
</Button>,
|
||||
);
|
||||
const btn = container.querySelector('button')!;
|
||||
btn.click();
|
||||
expect(handleClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('renders small size', () => {
|
||||
renderWithTheme(<Button size="small">Click</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.classList.contains('MuiButton-sizeSmall')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders medium size by default', () => {
|
||||
renderWithTheme(<Button>Click</Button>);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.classList.contains('MuiButton-sizeMedium')).toBe(true);
|
||||
});
|
||||
});
|
||||
48
packages/design-system/src/components/Button/Button.tsx
Normal file
48
packages/design-system/src/components/Button/Button.tsx
Normal file
@ -0,0 +1,48 @@
|
||||
import {
|
||||
Button as MuiButton,
|
||||
CircularProgress,
|
||||
type ButtonProps as MuiButtonProps,
|
||||
} from '@mui/material';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type ActionVariant = 'primary' | 'secondary' | 'tertiary' | 'danger';
|
||||
|
||||
export interface ButtonProps extends Omit<MuiButtonProps, 'variant' | 'color' | 'size' | 'sx'> {
|
||||
variant?: ActionVariant;
|
||||
size?: 'small' | 'medium';
|
||||
loading?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const VARIANT_MAP: Record<ActionVariant, MuiButtonProps['variant']> = {
|
||||
primary: 'contained',
|
||||
secondary: 'outlined',
|
||||
tertiary: 'text',
|
||||
danger: 'contained',
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = 'primary',
|
||||
size = 'medium',
|
||||
loading = false,
|
||||
disabled,
|
||||
children,
|
||||
...props
|
||||
}: ButtonProps) {
|
||||
const muiVariant = VARIANT_MAP[variant];
|
||||
const muiColor = variant === 'danger' ? 'error' : undefined;
|
||||
|
||||
return (
|
||||
<MuiButton
|
||||
variant={muiVariant}
|
||||
size={size}
|
||||
color={muiColor}
|
||||
disabled={disabled || loading}
|
||||
aria-busy={loading ? true : undefined}
|
||||
{...props}
|
||||
>
|
||||
{loading && <CircularProgress size={16} sx={{ mr: 1 }} />}
|
||||
{children}
|
||||
</MuiButton>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/Button/index.ts
Normal file
2
packages/design-system/src/components/Button/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Button } from './Button';
|
||||
export type { ButtonProps } from './Button';
|
||||
56
packages/design-system/src/components/Card/Card.stories.tsx
Normal file
56
packages/design-system/src/components/Card/Card.stories.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from '../Button/Button';
|
||||
import { Card } from './Card';
|
||||
|
||||
const meta: Meta<typeof Card> = {
|
||||
title: 'Surfaces/Card',
|
||||
component: Card,
|
||||
argTypes: {
|
||||
padding: {
|
||||
control: 'select',
|
||||
options: ['none', 'sm', 'md', 'lg'],
|
||||
},
|
||||
elevation: {
|
||||
control: 'select',
|
||||
options: ['none', 'sm', 'md'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Card>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
children: 'Основное содержимое карточки',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithHeader: Story = {
|
||||
args: {
|
||||
header: <strong>Заголовок карточки</strong>,
|
||||
children: 'Текст внутри карточки с заголовком',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithActions: Story = {
|
||||
args: {
|
||||
children: 'Карточка с действиями',
|
||||
actions: <Button size="small">Подробнее</Button>,
|
||||
},
|
||||
};
|
||||
|
||||
export const FullCard: Story = {
|
||||
args: {
|
||||
header: <strong>Портфель акций</strong>,
|
||||
children: 'Состав портфеля: Сбербанк, Лукойл, Газпром',
|
||||
actions: (
|
||||
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<Button size="small" variant="secondary">
|
||||
Отмена
|
||||
</Button>
|
||||
<Button size="small">Сохранить</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
39
packages/design-system/src/components/Card/Card.test.tsx
Normal file
39
packages/design-system/src/components/Card/Card.test.tsx
Normal file
@ -0,0 +1,39 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Card } from './Card';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Card', () => {
|
||||
it('renders children', () => {
|
||||
renderWithTheme(<Card>Body</Card>);
|
||||
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders header', () => {
|
||||
renderWithTheme(<Card header="Header">Body</Card>);
|
||||
expect(screen.getByText('Header')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders actions', () => {
|
||||
renderWithTheme(<Card actions={<button>Action</button>}>Body</Card>);
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts Surface padding prop', () => {
|
||||
renderWithTheme(
|
||||
<Card padding="none" header="Header">
|
||||
Body
|
||||
</Card>,
|
||||
);
|
||||
expect(screen.getByText('Body').closest('.MuiPaper-root')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders without header and actions', () => {
|
||||
renderWithTheme(<Card>Body</Card>);
|
||||
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
17
packages/design-system/src/components/Card/Card.tsx
Normal file
17
packages/design-system/src/components/Card/Card.tsx
Normal file
@ -0,0 +1,17 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Surface, type SurfaceProps } from '../Surface/Surface';
|
||||
|
||||
export interface CardProps extends SurfaceProps {
|
||||
header?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function Card({ header, actions, children, ...surfaceProps }: CardProps) {
|
||||
return (
|
||||
<Surface {...surfaceProps}>
|
||||
{header && <div>{header}</div>}
|
||||
{children}
|
||||
{actions && <div>{actions}</div>}
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/Card/index.ts
Normal file
2
packages/design-system/src/components/Card/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Card } from './Card';
|
||||
export type { CardProps } from './Card';
|
||||
@ -0,0 +1,51 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { fn } from '@storybook/test';
|
||||
import { Checkbox } from './Checkbox';
|
||||
|
||||
const meta: Meta<typeof Checkbox> = {
|
||||
title: 'Inputs/Checkbox',
|
||||
component: Checkbox,
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
checked: { control: 'boolean' },
|
||||
disabled: { control: 'boolean' },
|
||||
error: { control: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Checkbox>;
|
||||
|
||||
export const Unchecked: Story = {
|
||||
args: {
|
||||
label: 'Согласен с условиями',
|
||||
checked: false,
|
||||
onChange: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export const Checked: Story = {
|
||||
args: {
|
||||
label: 'Согласен с условиями',
|
||||
checked: true,
|
||||
onChange: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
label: 'Недоступно',
|
||||
checked: false,
|
||||
onChange: fn(),
|
||||
disabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
label: 'Согласен с условиями',
|
||||
checked: false,
|
||||
onChange: fn(),
|
||||
error: true,
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,57 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Checkbox } from './Checkbox';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Checkbox', () => {
|
||||
it('renders label', () => {
|
||||
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={() => {}} />);
|
||||
expect(screen.getByText('Agree')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles on label click', async () => {
|
||||
const handleChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={handleChange} />);
|
||||
await user.click(screen.getByText('Agree'));
|
||||
expect(handleChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('toggles on checkbox click', async () => {
|
||||
const handleChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={handleChange} />);
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
expect(handleChange).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('calls onChange with false when checked', async () => {
|
||||
const handleChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithTheme(<Checkbox label="Agree" checked={true} onChange={handleChange} />);
|
||||
await user.click(screen.getByRole('checkbox'));
|
||||
expect(handleChange).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('can be disabled', () => {
|
||||
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={() => {}} disabled />);
|
||||
expect(screen.getByRole('checkbox')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('shows error state', () => {
|
||||
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={() => {}} error />);
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
expect(checkbox).toHaveAttribute('aria-invalid', 'true');
|
||||
});
|
||||
|
||||
it('does not pass sx to DOM', () => {
|
||||
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={() => {}} />);
|
||||
const checkbox = screen.getByRole('checkbox');
|
||||
expect(checkbox).not.toHaveAttribute('sx');
|
||||
});
|
||||
});
|
||||
26
packages/design-system/src/components/Checkbox/Checkbox.tsx
Normal file
26
packages/design-system/src/components/Checkbox/Checkbox.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { FormControlLabel, Checkbox as MuiCheckbox } from '@mui/material';
|
||||
|
||||
export interface CheckboxProps {
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
disabled?: boolean;
|
||||
error?: boolean;
|
||||
}
|
||||
|
||||
export function Checkbox({ label, checked, onChange, disabled, error }: CheckboxProps) {
|
||||
return (
|
||||
<FormControlLabel
|
||||
control={
|
||||
<MuiCheckbox
|
||||
checked={checked}
|
||||
onChange={(_, ch) => onChange(ch)}
|
||||
disabled={disabled}
|
||||
inputProps={error ? { 'aria-invalid': 'true' as const } : undefined}
|
||||
/>
|
||||
}
|
||||
label={label}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/Checkbox/index.ts
Normal file
2
packages/design-system/src/components/Checkbox/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Checkbox } from './Checkbox';
|
||||
export type { CheckboxProps } from './Checkbox';
|
||||
74
packages/design-system/src/components/Chip/Chip.stories.tsx
Normal file
74
packages/design-system/src/components/Chip/Chip.stories.tsx
Normal file
@ -0,0 +1,74 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { fn } from '@storybook/test';
|
||||
import { Chip } from './Chip';
|
||||
|
||||
const meta: Meta<typeof Chip> = {
|
||||
title: 'Surfaces/Chip',
|
||||
component: Chip,
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
tone: {
|
||||
control: 'select',
|
||||
options: ['neutral', 'info', 'success', 'warning', 'error'],
|
||||
},
|
||||
onDelete: { action: 'deleted' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Chip>;
|
||||
|
||||
export const Neutral: Story = {
|
||||
args: {
|
||||
label: 'Черновик',
|
||||
tone: 'neutral',
|
||||
},
|
||||
};
|
||||
|
||||
export const Info: Story = {
|
||||
args: {
|
||||
label: 'Новое',
|
||||
tone: 'info',
|
||||
},
|
||||
};
|
||||
|
||||
export const Success: Story = {
|
||||
args: {
|
||||
label: 'Активно',
|
||||
tone: 'success',
|
||||
},
|
||||
};
|
||||
|
||||
export const Warning: Story = {
|
||||
args: {
|
||||
label: 'Внимание',
|
||||
tone: 'warning',
|
||||
},
|
||||
};
|
||||
|
||||
export const Error: Story = {
|
||||
args: {
|
||||
label: 'Ошибка',
|
||||
tone: 'error',
|
||||
},
|
||||
};
|
||||
|
||||
export const Deletable: Story = {
|
||||
args: {
|
||||
label: 'Акция',
|
||||
tone: 'info',
|
||||
onDelete: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export const AllTones: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<Chip label="Черновик" tone="neutral" />
|
||||
<Chip label="Новое" tone="info" />
|
||||
<Chip label="Активно" tone="success" />
|
||||
<Chip label="Внимание" tone="warning" />
|
||||
<Chip label="Ошибка" tone="error" />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
62
packages/design-system/src/components/Chip/Chip.test.tsx
Normal file
62
packages/design-system/src/components/Chip/Chip.test.tsx
Normal file
@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Chip } from './Chip';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Chip', () => {
|
||||
it('renders label', () => {
|
||||
renderWithTheme(<Chip label="Default" />);
|
||||
expect(screen.getByText('Default')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with neutral tone by default', () => {
|
||||
renderWithTheme(<Chip label="Neutral" />);
|
||||
const chip = screen.getByText('Neutral').closest('.MuiChip-root')!;
|
||||
expect(chip.classList.contains('MuiChip-filled')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders info tone', () => {
|
||||
renderWithTheme(<Chip label="Info" tone="info" />);
|
||||
expect(screen.getByText('Info')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders success tone', () => {
|
||||
renderWithTheme(<Chip label="Success" tone="success" />);
|
||||
expect(screen.getByText('Success')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders warning tone', () => {
|
||||
renderWithTheme(<Chip label="Warning" tone="warning" />);
|
||||
expect(screen.getByText('Warning')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error tone', () => {
|
||||
renderWithTheme(<Chip label="Error" tone="error" />);
|
||||
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows delete button when onDelete provided', () => {
|
||||
renderWithTheme(<Chip label="Removable" onDelete={() => {}} />);
|
||||
const deleteIcon = screen.getByLabelText('Remove Removable');
|
||||
expect(deleteIcon).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onDelete when delete clicked', async () => {
|
||||
const handleDelete = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithTheme(<Chip label="Removable" onDelete={handleDelete} />);
|
||||
const deleteIcon = screen.getByLabelText('Remove Removable');
|
||||
await user.click(deleteIcon);
|
||||
expect(handleDelete).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not show delete button without onDelete', () => {
|
||||
renderWithTheme(<Chip label="Static" />);
|
||||
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
29
packages/design-system/src/components/Chip/Chip.tsx
Normal file
29
packages/design-system/src/components/Chip/Chip.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { Chip as MuiChip } from '@mui/material';
|
||||
import CancelIcon from '@mui/icons-material/Cancel';
|
||||
|
||||
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
||||
|
||||
const TONE_MAP: Record<Tone, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||
neutral: 'default',
|
||||
info: 'info',
|
||||
success: 'success',
|
||||
warning: 'warning',
|
||||
error: 'error',
|
||||
};
|
||||
|
||||
export interface ChipProps {
|
||||
label: string;
|
||||
tone?: Tone;
|
||||
onDelete?: () => void;
|
||||
}
|
||||
|
||||
export function Chip({ label, tone = 'neutral', onDelete }: ChipProps) {
|
||||
return (
|
||||
<MuiChip
|
||||
label={label}
|
||||
color={TONE_MAP[tone]}
|
||||
onDelete={onDelete}
|
||||
{...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/Chip/index.ts
Normal file
2
packages/design-system/src/components/Chip/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Chip } from './Chip';
|
||||
export type { ChipProps } from './Chip';
|
||||
@ -0,0 +1,121 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { DataTable } from './DataTable';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
createColumnHelper,
|
||||
getSortedRowModel,
|
||||
} from '@tanstack/react-table';
|
||||
import type { SortingState } from '@tanstack/react-table';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface Stock {
|
||||
ticker: string;
|
||||
price: number;
|
||||
change: number;
|
||||
}
|
||||
|
||||
const data: Stock[] = [
|
||||
{ ticker: 'SBER', price: 281.5, change: 1.2 },
|
||||
{ ticker: 'GAZP', price: 162.3, change: -0.5 },
|
||||
{ ticker: 'LKOH', price: 7_112.0, change: 2.1 },
|
||||
{ ticker: 'YNDX', price: 5_413.0, change: -1.8 },
|
||||
];
|
||||
|
||||
const columnHelper = createColumnHelper<Stock>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('ticker', { header: 'Тикер' }),
|
||||
columnHelper.accessor('price', { header: 'Цена' }),
|
||||
columnHelper.accessor('change', { header: 'Изменение' }),
|
||||
];
|
||||
|
||||
function SortableTableStory() {
|
||||
const [sorting, setSorting] = useState<SortingState>([]);
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
state: { sorting },
|
||||
onSortingChange: setSorting,
|
||||
});
|
||||
return (
|
||||
<MoexVibeThemeProvider>
|
||||
<DataTable table={table} caption="Котировки акций" />
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
const meta: Meta<typeof DataTable> = {
|
||||
title: 'Data/DataTable',
|
||||
component: DataTable,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof DataTable>;
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
return (
|
||||
<MoexVibeThemeProvider>
|
||||
<DataTable table={table} caption="Котировки акций" />
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Compact: Story = {
|
||||
render: () => {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
return (
|
||||
<MoexVibeThemeProvider>
|
||||
<DataTable table={table} caption="Котировки акций" density="compact" />
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Sortable: Story = {
|
||||
render: () => <SortableTableStory />,
|
||||
};
|
||||
|
||||
export const Empty: Story = {
|
||||
render: () => {
|
||||
const table = useReactTable({
|
||||
data: [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
return (
|
||||
<MoexVibeThemeProvider>
|
||||
<DataTable table={table} caption="Котировки акций" empty={<div>Нет данных</div>} />
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const Loading: Story = {
|
||||
render: () => {
|
||||
const table = useReactTable({
|
||||
data: [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
return (
|
||||
<MoexVibeThemeProvider>
|
||||
<DataTable table={table} caption="Котировки акций" loading />
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,97 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { DataTable } from './DataTable';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
import { useReactTable, getCoreRowModel, createColumnHelper } from '@tanstack/react-table';
|
||||
|
||||
interface Item {
|
||||
name: string;
|
||||
price: number;
|
||||
}
|
||||
|
||||
const data: Item[] = [
|
||||
{ name: 'AAPL', price: 250 },
|
||||
{ name: 'GOOGL', price: 180 },
|
||||
];
|
||||
|
||||
const columnHelper = createColumnHelper<Item>();
|
||||
|
||||
const columns = [
|
||||
columnHelper.accessor('name', { header: 'Name' }),
|
||||
columnHelper.accessor('price', { header: 'Price' }),
|
||||
];
|
||||
|
||||
function TestTable({ caption }: { caption: string }) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
return (
|
||||
<MoexVibeThemeProvider>
|
||||
<DataTable table={table} caption={caption} />
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function TestTableWithProps(props: {
|
||||
caption: string;
|
||||
loading?: boolean;
|
||||
empty?: React.ReactNode;
|
||||
density?: 'balanced' | 'compact';
|
||||
}) {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
return (
|
||||
<MoexVibeThemeProvider>
|
||||
<DataTable table={table} {...props} />
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyTable() {
|
||||
const table = useReactTable({
|
||||
data: [],
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
return (
|
||||
<MoexVibeThemeProvider>
|
||||
<DataTable table={table} caption="Empty" empty={<div>No data</div>} />
|
||||
</MoexVibeThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('DataTable', () => {
|
||||
it('renders caption', () => {
|
||||
render(<TestTable caption="Test Caption" />);
|
||||
expect(screen.getByText('Test Caption')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders column headers as th elements', () => {
|
||||
render(<TestTable caption="Stocks" />);
|
||||
const headers = screen.getAllByRole('columnheader');
|
||||
expect(headers).toHaveLength(2);
|
||||
expect(headers[0]).toHaveTextContent('Name');
|
||||
expect(headers[1]).toHaveTextContent('Price');
|
||||
});
|
||||
|
||||
it('renders data rows', () => {
|
||||
render(<TestTable caption="Stocks" />);
|
||||
expect(screen.getByText('AAPL')).toBeInTheDocument();
|
||||
expect(screen.getByText('GOOGL')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with compact density', () => {
|
||||
render(<TestTableWithProps caption="Stocks" density="compact" />);
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows empty state when no data', () => {
|
||||
render(<EmptyTable />);
|
||||
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,73 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Table,
|
||||
TableContainer,
|
||||
TableHead,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableCell,
|
||||
type TableProps as MuiTableProps,
|
||||
} from '@mui/material';
|
||||
import type { Table as TanStackTable } from '@tanstack/react-table';
|
||||
import { flexRender } from '@tanstack/react-table';
|
||||
|
||||
export type Density = 'balanced' | 'compact';
|
||||
|
||||
export interface DataTableProps<T> {
|
||||
table: TanStackTable<T>;
|
||||
density?: Density;
|
||||
loading?: boolean;
|
||||
empty?: ReactNode;
|
||||
caption: string;
|
||||
}
|
||||
|
||||
const DENSITY_PADDING: Record<Density, MuiTableProps['size']> = {
|
||||
balanced: 'medium',
|
||||
compact: 'small',
|
||||
};
|
||||
|
||||
export function DataTable<T>({
|
||||
table,
|
||||
density = 'balanced',
|
||||
loading,
|
||||
empty,
|
||||
caption,
|
||||
}: DataTableProps<T>) {
|
||||
const rows = table.getRowModel().rows;
|
||||
|
||||
if (!loading && rows.length === 0 && empty) {
|
||||
return <>{empty}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableContainer>
|
||||
<Table size={DENSITY_PADDING[density]}>
|
||||
<caption>{caption}</caption>
|
||||
<TableHead>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableCell key={header.id} sortDirection={header.column.getIsSorted() || false}>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{rows.map((row) => (
|
||||
<TableRow key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/DataTable/index.ts
Normal file
2
packages/design-system/src/components/DataTable/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { DataTable } from './DataTable';
|
||||
export type { DataTableProps, Density } from './DataTable';
|
||||
@ -0,0 +1,61 @@
|
||||
import { useState } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { fn } from '@storybook/test';
|
||||
import { Button } from '../Button/Button';
|
||||
import { Dialog } from './Dialog';
|
||||
|
||||
const meta: Meta<typeof Dialog> = {
|
||||
title: 'Feedback/Dialog',
|
||||
component: Dialog,
|
||||
argTypes: {
|
||||
open: { control: 'boolean' },
|
||||
title: { control: 'text' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Dialog>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
onClose: fn(),
|
||||
title: 'Подтверждение',
|
||||
children: 'Вы уверены, что хотите удалить этот портфель?',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithActions: Story = {
|
||||
args: {
|
||||
open: true,
|
||||
onClose: fn(),
|
||||
title: 'Удаление портфеля',
|
||||
children: 'Портфель "Акции США" будет безвозвратно удалён',
|
||||
actions: (
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<Button variant="secondary" onClick={fn()}>
|
||||
Отмена
|
||||
</Button>
|
||||
<Button variant="danger" onClick={fn()}>
|
||||
Удалить
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
export const Interactive: Story = {
|
||||
render: () => {
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
const [open, setOpen] = useState(false);
|
||||
return (
|
||||
<>
|
||||
<Button onClick={() => setOpen(true)}>Открыть диалог</Button>
|
||||
<Dialog open={open} onClose={() => setOpen(false)} title="Модальное окно">
|
||||
<p>Это интерактивный пример диалога.</p>
|
||||
<p>Нажмите Escape или кликните вне окна для закрытия.</p>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
81
packages/design-system/src/components/Dialog/Dialog.test.tsx
Normal file
81
packages/design-system/src/components/Dialog/Dialog.test.tsx
Normal file
@ -0,0 +1,81 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { Dialog } from './Dialog';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Dialog', () => {
|
||||
it('renders when open', () => {
|
||||
renderWithTheme(
|
||||
<Dialog open={true} onClose={() => {}} title="Confirm">
|
||||
Are you sure?
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not render when closed', () => {
|
||||
renderWithTheme(
|
||||
<Dialog open={false} onClose={() => {}} title="Confirm">
|
||||
Are you sure?
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('has aria-labelledby pointing to title', () => {
|
||||
renderWithTheme(
|
||||
<Dialog open={true} onClose={() => {}} title="Confirm">
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
const dialog = screen.getByRole('dialog');
|
||||
const titleId = dialog.getAttribute('aria-labelledby');
|
||||
expect(titleId).toBeTruthy();
|
||||
const titleEl = document.getElementById(titleId!);
|
||||
expect(titleEl).toHaveTextContent('Confirm');
|
||||
});
|
||||
|
||||
it('calls onClose on Escape', async () => {
|
||||
const handleClose = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
renderWithTheme(
|
||||
<Dialog open={true} onClose={handleClose} title="Confirm">
|
||||
Are you sure?
|
||||
</Dialog>,
|
||||
);
|
||||
await user.keyboard('{Escape}');
|
||||
expect(handleClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('renders title', () => {
|
||||
renderWithTheme(
|
||||
<Dialog open={true} onClose={() => {}} title="Confirm">
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText('Confirm')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
renderWithTheme(
|
||||
<Dialog open={true} onClose={() => {}} title="Confirm">
|
||||
Are you sure?
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByText('Are you sure?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders actions', () => {
|
||||
renderWithTheme(
|
||||
<Dialog open={true} onClose={() => {}} title="Confirm" actions={<button>OK</button>}>
|
||||
Content
|
||||
</Dialog>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
23
packages/design-system/src/components/Dialog/Dialog.tsx
Normal file
23
packages/design-system/src/components/Dialog/Dialog.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import { useId } from 'react';
|
||||
import { Dialog as MuiDialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface DialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function Dialog({ open, onClose, title, children, actions }: DialogProps) {
|
||||
const titleId = useId();
|
||||
|
||||
return (
|
||||
<MuiDialog open={open} onClose={onClose} aria-labelledby={titleId}>
|
||||
<DialogTitle id={titleId}>{title}</DialogTitle>
|
||||
<DialogContent>{children}</DialogContent>
|
||||
{actions && <DialogActions>{actions}</DialogActions>}
|
||||
</MuiDialog>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/Dialog/index.ts
Normal file
2
packages/design-system/src/components/Dialog/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Dialog } from './Dialog';
|
||||
export type { DialogProps } from './Dialog';
|
||||
@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from '../Button';
|
||||
import { EmptyState } from './EmptyState';
|
||||
|
||||
const meta: Meta<typeof EmptyState> = {
|
||||
title: 'Feedback/EmptyState',
|
||||
component: EmptyState,
|
||||
argTypes: {
|
||||
title: { control: 'text' },
|
||||
description: { control: 'text' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof EmptyState>;
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
title: 'No data available',
|
||||
description: 'Try adjusting your filters or date range.',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAction: Story = {
|
||||
args: {
|
||||
title: 'No securities found',
|
||||
description: 'Try a different search query.',
|
||||
action: <Button variant="outlined">Clear filters</Button>,
|
||||
},
|
||||
};
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
title: 'Nothing here yet',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { EmptyState } from './EmptyState';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('EmptyState', () => {
|
||||
it('renders title as semantic heading', () => {
|
||||
renderWithTheme(<EmptyState title="No data" />);
|
||||
const heading = screen.getByText('No data');
|
||||
expect(heading.tagName).toBe('H2');
|
||||
});
|
||||
|
||||
it('renders description', () => {
|
||||
renderWithTheme(<EmptyState title="No data" description="Try adjusting filters" />);
|
||||
expect(screen.getByText('Try adjusting filters')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders action', () => {
|
||||
renderWithTheme(<EmptyState title="No data" action={<button>Refresh</button>} />);
|
||||
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Stack } from '@mui/material';
|
||||
import { Heading } from '../Heading';
|
||||
import { Text } from '../Text';
|
||||
|
||||
export interface EmptyStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||
return (
|
||||
<Stack spacing={1} alignItems="center" data-testid="empty-state">
|
||||
<Heading level={2}>{title}</Heading>
|
||||
{description && <Text>{description}</Text>}
|
||||
{action && <div>{action}</div>}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { EmptyState } from './EmptyState';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
@ -0,0 +1,36 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from '../Button';
|
||||
import { ErrorState } from './ErrorState';
|
||||
|
||||
const meta: Meta<typeof ErrorState> = {
|
||||
title: 'Feedback/ErrorState',
|
||||
component: ErrorState,
|
||||
argTypes: {
|
||||
title: { control: 'text' },
|
||||
description: { control: 'text' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ErrorState>;
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
title: 'Failed to load',
|
||||
description: 'An unexpected error occurred. Please try again.',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRetry: Story = {
|
||||
args: {
|
||||
title: 'Connection lost',
|
||||
description: 'Could not reach the server.',
|
||||
action: <Button>Retry</Button>,
|
||||
},
|
||||
};
|
||||
|
||||
export const Minimal: Story = {
|
||||
args: {
|
||||
title: 'Error loading data',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { ErrorState } from './ErrorState';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('ErrorState', () => {
|
||||
it('renders title as semantic heading', () => {
|
||||
renderWithTheme(<ErrorState title="Error loading" />);
|
||||
const heading = screen.getByText('Error loading');
|
||||
expect(heading.tagName).toBe('H2');
|
||||
});
|
||||
|
||||
it('renders description', () => {
|
||||
renderWithTheme(<ErrorState title="Error" description="Something went wrong" />);
|
||||
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders action', () => {
|
||||
renderWithTheme(<ErrorState title="Error" action={<button>Retry</button>} />);
|
||||
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,20 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Stack } from '@mui/material';
|
||||
import { Heading } from '../Heading';
|
||||
import { Text } from '../Text';
|
||||
|
||||
export interface ErrorStateProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function ErrorState({ title, description, action }: ErrorStateProps) {
|
||||
return (
|
||||
<Stack spacing={1} alignItems="center" data-testid="error-state">
|
||||
<Heading level={2}>{title}</Heading>
|
||||
{description && <Text>{description}</Text>}
|
||||
{action && <div>{action}</div>}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { ErrorState } from './ErrorState';
|
||||
export type { ErrorStateProps } from './ErrorState';
|
||||
@ -0,0 +1,38 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Button } from '../Button';
|
||||
import { TextField } from '../TextField';
|
||||
import { Select } from '../Select';
|
||||
import { FilterBar } from './FilterBar';
|
||||
|
||||
const meta: Meta<typeof FilterBar> = {
|
||||
title: 'Layout/FilterBar',
|
||||
component: FilterBar,
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FilterBar>;
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
children: (
|
||||
<>
|
||||
<TextField placeholder="Search" />
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'active', label: 'Active' },
|
||||
]}
|
||||
value="all"
|
||||
onChange={() => {}}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
actions: <Button>Apply</Button>,
|
||||
},
|
||||
};
|
||||
|
||||
export const NoActions: Story = {
|
||||
args: {
|
||||
children: <TextField placeholder="Search securities..." />,
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FilterBar } from './FilterBar';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('FilterBar', () => {
|
||||
it('renders children', () => {
|
||||
renderWithTheme(
|
||||
<FilterBar>
|
||||
<input placeholder="Search" />
|
||||
</FilterBar>,
|
||||
);
|
||||
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders actions', () => {
|
||||
renderWithTheme(
|
||||
<FilterBar actions={<button>Apply</button>}>
|
||||
<input placeholder="Filter" />
|
||||
</FilterBar>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,22 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Stack } from '@mui/material';
|
||||
|
||||
export interface FilterBarProps {
|
||||
children: ReactNode;
|
||||
actions?: ReactNode;
|
||||
}
|
||||
|
||||
export function FilterBar({ children, actions }: FilterBarProps) {
|
||||
return (
|
||||
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap" data-testid="filter-bar">
|
||||
<Stack direction="row" spacing={1} flexWrap="wrap" flex={1}>
|
||||
{children}
|
||||
</Stack>
|
||||
{actions && (
|
||||
<Stack direction="row" spacing={1}>
|
||||
{actions}
|
||||
</Stack>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/FilterBar/index.ts
Normal file
2
packages/design-system/src/components/FilterBar/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { FilterBar } from './FilterBar';
|
||||
export type { FilterBarProps } from './FilterBar';
|
||||
@ -0,0 +1,64 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { TextField } from '../TextField';
|
||||
import { Select } from '../Select';
|
||||
import { FormField } from './FormField';
|
||||
|
||||
const meta: Meta<typeof FormField> = {
|
||||
title: 'Form/FormField',
|
||||
component: FormField,
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
helperText: { control: 'text' },
|
||||
error: { control: 'text' },
|
||||
required: { control: 'boolean' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof FormField>;
|
||||
|
||||
export const WithTextField: Story = {
|
||||
args: {
|
||||
label: 'Username',
|
||||
htmlFor: 'username',
|
||||
helperText: 'Enter your username',
|
||||
children: <TextField id="username" placeholder="username" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Required: Story = {
|
||||
args: {
|
||||
label: 'Email',
|
||||
htmlFor: 'email',
|
||||
required: true,
|
||||
helperText: 'We will never share your email',
|
||||
children: <TextField id="email" type="email" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: {
|
||||
label: 'Name',
|
||||
htmlFor: 'name',
|
||||
error: 'Name is required',
|
||||
children: <TextField id="name" value="" />,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSelect: Story = {
|
||||
args: {
|
||||
label: 'Country',
|
||||
htmlFor: 'country',
|
||||
children: (
|
||||
<Select
|
||||
id="country"
|
||||
options={[
|
||||
{ value: 'ru', label: 'Russia' },
|
||||
{ value: 'us', label: 'United States' },
|
||||
]}
|
||||
value=""
|
||||
onChange={() => {}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,48 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { FormField } from './FormField';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('FormField', () => {
|
||||
it('renders label with htmlFor', () => {
|
||||
renderWithTheme(
|
||||
<FormField label="Username" htmlFor="username">
|
||||
<input id="username" />
|
||||
</FormField>,
|
||||
);
|
||||
const label = screen.getByText('Username');
|
||||
expect(label.tagName).toBe('LABEL');
|
||||
expect(label).toHaveAttribute('for', 'username');
|
||||
});
|
||||
|
||||
it('renders helper text', () => {
|
||||
renderWithTheme(
|
||||
<FormField label="Email" htmlFor="email" helperText="Enter your email">
|
||||
<input id="email" />
|
||||
</FormField>,
|
||||
);
|
||||
expect(screen.getByText('Enter your email')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders error text and associates with input', () => {
|
||||
renderWithTheme(
|
||||
<FormField label="Name" htmlFor="name" error="Name is required">
|
||||
<input id="name" />
|
||||
</FormField>,
|
||||
);
|
||||
expect(screen.getByText('Name is required')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders required indicator', () => {
|
||||
renderWithTheme(
|
||||
<FormField label="Email" htmlFor="email" required>
|
||||
<input id="email" />
|
||||
</FormField>,
|
||||
);
|
||||
expect(screen.getByText('*')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,28 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { FormControl, FormLabel, FormHelperText, Box } from '@mui/material';
|
||||
|
||||
export interface FormFieldProps {
|
||||
label: string;
|
||||
htmlFor: string;
|
||||
helperText?: string;
|
||||
error?: string;
|
||||
required?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function FormField({
|
||||
label,
|
||||
htmlFor,
|
||||
helperText,
|
||||
error,
|
||||
required,
|
||||
children,
|
||||
}: FormFieldProps) {
|
||||
return (
|
||||
<FormControl error={!!error} required={required} fullWidth>
|
||||
<FormLabel htmlFor={htmlFor}>{label}</FormLabel>
|
||||
<Box sx={{ mt: 0.5 }}>{children}</Box>
|
||||
{(helperText || error) && <FormHelperText>{error || helperText}</FormHelperText>}
|
||||
</FormControl>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/FormField/index.ts
Normal file
2
packages/design-system/src/components/FormField/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { FormField } from './FormField';
|
||||
export type { FormFieldProps } from './FormField';
|
||||
@ -0,0 +1,92 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Heading } from './Heading';
|
||||
|
||||
const meta: Meta<typeof Heading> = {
|
||||
title: 'Typography/Heading',
|
||||
component: Heading,
|
||||
argTypes: {
|
||||
level: {
|
||||
control: 'select',
|
||||
options: [1, 2, 3, 4, 5, 6],
|
||||
},
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['display', 'title', 'section', 'subsection'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Heading>;
|
||||
|
||||
export const Level1: Story = {
|
||||
args: {
|
||||
level: 1,
|
||||
children: 'Обзор рынка',
|
||||
},
|
||||
};
|
||||
|
||||
export const Level2: Story = {
|
||||
args: {
|
||||
level: 2,
|
||||
children: 'Акции',
|
||||
},
|
||||
};
|
||||
|
||||
export const Level3: Story = {
|
||||
args: {
|
||||
level: 3,
|
||||
children: 'Голубые фишки',
|
||||
},
|
||||
};
|
||||
|
||||
export const DisplaySize: Story = {
|
||||
args: {
|
||||
level: 1,
|
||||
size: 'display',
|
||||
children: 'Московская Биржа',
|
||||
},
|
||||
};
|
||||
|
||||
export const TitleSize: Story = {
|
||||
args: {
|
||||
level: 2,
|
||||
size: 'title',
|
||||
children: 'Индекс Мосбиржи обновил максимум',
|
||||
},
|
||||
};
|
||||
|
||||
export const SectionSize: Story = {
|
||||
args: {
|
||||
level: 3,
|
||||
size: 'section',
|
||||
children: 'Нефтегазовый сектор',
|
||||
},
|
||||
};
|
||||
|
||||
export const SubsectionSize: Story = {
|
||||
args: {
|
||||
level: 4,
|
||||
size: 'subsection',
|
||||
children: 'Лукойл',
|
||||
},
|
||||
};
|
||||
|
||||
export const AllLevels: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Heading level={1} size="title">
|
||||
H1 — Заголовок страницы
|
||||
</Heading>
|
||||
<Heading level={2} size="section">
|
||||
H2 — Раздел
|
||||
</Heading>
|
||||
<Heading level={3} size="subsection">
|
||||
H3 — Подраздел
|
||||
</Heading>
|
||||
<Heading level={4}>H4 — Группа</Heading>
|
||||
<Heading level={5}>H5 — Элемент</Heading>
|
||||
<Heading level={6}>H6 — Мелкий заголовок</Heading>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@ -0,0 +1,55 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Heading } from './Heading';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Heading', () => {
|
||||
it('renders children', () => {
|
||||
renderWithTheme(<Heading level={1}>Title</Heading>);
|
||||
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders h1 for level 1', () => {
|
||||
renderWithTheme(<Heading level={1}>H1</Heading>);
|
||||
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders h2 for level 2', () => {
|
||||
renderWithTheme(<Heading level={2}>H2</Heading>);
|
||||
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders h3 for level 3', () => {
|
||||
renderWithTheme(<Heading level={3}>H3</Heading>);
|
||||
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders h4 for level 4', () => {
|
||||
renderWithTheme(<Heading level={4}>H4</Heading>);
|
||||
expect(screen.getByRole('heading', { level: 4 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders h5 for level 5', () => {
|
||||
renderWithTheme(<Heading level={5}>H5</Heading>);
|
||||
expect(screen.getByRole('heading', { level: 5 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders h6 for level 6', () => {
|
||||
renderWithTheme(<Heading level={6}>H6</Heading>);
|
||||
expect(screen.getByRole('heading', { level: 6 })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts className and style props', () => {
|
||||
renderWithTheme(
|
||||
<Heading level={1} className="custom" style={{ margin: 8 }}>
|
||||
Hello
|
||||
</Heading>,
|
||||
);
|
||||
const el = screen.getByText('Hello');
|
||||
expect(el.classList.contains('custom')).toBe(true);
|
||||
});
|
||||
});
|
||||
25
packages/design-system/src/components/Heading/Heading.tsx
Normal file
25
packages/design-system/src/components/Heading/Heading.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Typography, type TypographyProps } from '@mui/material';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Size = 'display' | 'title' | 'section' | 'subsection';
|
||||
|
||||
export interface HeadingProps extends Omit<TypographyProps, 'variant' | 'color' | 'sx'> {
|
||||
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
size?: Size;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const SIZE_VARIANT: Record<Size, TypographyProps['variant']> = {
|
||||
display: 'h1',
|
||||
title: 'h2',
|
||||
section: 'h3',
|
||||
subsection: 'h4',
|
||||
};
|
||||
|
||||
export function Heading({ level, size = 'title', children, ...props }: HeadingProps) {
|
||||
return (
|
||||
<Typography variant={SIZE_VARIANT[size]} component={`h${level}`} {...props}>
|
||||
{children}
|
||||
</Typography>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/Heading/index.ts
Normal file
2
packages/design-system/src/components/Heading/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Heading } from './Heading';
|
||||
export type { HeadingProps } from './Heading';
|
||||
@ -0,0 +1,88 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { IconButton } from './IconButton';
|
||||
import DeleteIcon from '@mui/icons-material/Delete';
|
||||
import SettingsIcon from '@mui/icons-material/Settings';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
|
||||
const meta: Meta<typeof IconButton> = {
|
||||
title: 'Actions/IconButton',
|
||||
component: IconButton,
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['small', 'medium'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof IconButton>;
|
||||
|
||||
export const Search: Story = {
|
||||
args: {
|
||||
label: 'Поиск',
|
||||
children: <SearchIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Close: Story = {
|
||||
args: {
|
||||
label: 'Закрыть',
|
||||
children: <CloseIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Delete: Story = {
|
||||
args: {
|
||||
label: 'Удалить',
|
||||
children: <DeleteIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Settings: Story = {
|
||||
args: {
|
||||
label: 'Настройки',
|
||||
children: <SettingsIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export const Small: Story = {
|
||||
args: {
|
||||
size: 'small',
|
||||
label: 'Поиск',
|
||||
children: <SearchIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
export const AllSizes: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<IconButton size="small" label="Поиск (маленькая)">
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
<IconButton size="medium" label="Поиск (средняя)">
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
export const AllExamples: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<IconButton label="Поиск">
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
<IconButton label="Настройки">
|
||||
<SettingsIcon />
|
||||
</IconButton>
|
||||
<IconButton label="Удалить">
|
||||
<DeleteIcon />
|
||||
</IconButton>
|
||||
<IconButton label="Закрыть">
|
||||
<CloseIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@ -0,0 +1,68 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { IconButton } from './IconButton';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('IconButton', () => {
|
||||
it('renders with aria-label', () => {
|
||||
renderWithTheme(
|
||||
<IconButton label="Close">
|
||||
<span>X</span>
|
||||
</IconButton>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children', () => {
|
||||
renderWithTheme(
|
||||
<IconButton label="Menu">
|
||||
<span>☰</span>
|
||||
</IconButton>,
|
||||
);
|
||||
expect(screen.getByText('☰')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not pass color prop to DOM', () => {
|
||||
renderWithTheme(
|
||||
<IconButton label="Search">
|
||||
<span>🔍</span>
|
||||
</IconButton>,
|
||||
);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn).not.toHaveAttribute('color');
|
||||
});
|
||||
|
||||
it('accepts className and style props', () => {
|
||||
renderWithTheme(
|
||||
<IconButton label="Settings" className="custom" style={{ margin: 4 }}>
|
||||
<span>⚙</span>
|
||||
</IconButton>,
|
||||
);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.classList.contains('custom')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders small size', () => {
|
||||
renderWithTheme(
|
||||
<IconButton label="Small" size="small">
|
||||
<span>S</span>
|
||||
</IconButton>,
|
||||
);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.classList.contains('MuiIconButton-sizeSmall')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders medium size by default', () => {
|
||||
renderWithTheme(
|
||||
<IconButton label="Medium">
|
||||
<span>M</span>
|
||||
</IconButton>,
|
||||
);
|
||||
const btn = screen.getByRole('button');
|
||||
expect(btn.classList.contains('MuiIconButton-sizeMedium')).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,22 @@
|
||||
import {
|
||||
IconButton as MuiIconButton,
|
||||
type IconButtonProps as MuiIconButtonProps,
|
||||
} from '@mui/material';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface IconButtonProps extends Omit<
|
||||
MuiIconButtonProps,
|
||||
'color' | 'variant' | 'sx' | 'aria-label'
|
||||
> {
|
||||
label: string;
|
||||
size?: 'small' | 'medium';
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function IconButton({ label, size = 'medium', children, ...props }: IconButtonProps) {
|
||||
return (
|
||||
<MuiIconButton aria-label={label} size={size} {...props}>
|
||||
{children}
|
||||
</MuiIconButton>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { IconButton } from './IconButton';
|
||||
export type { IconButtonProps } from './IconButton';
|
||||
70
packages/design-system/src/components/Link/Link.stories.tsx
Normal file
70
packages/design-system/src/components/Link/Link.stories.tsx
Normal file
@ -0,0 +1,70 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Link } from './Link';
|
||||
|
||||
const meta: Meta<typeof Link> = {
|
||||
title: 'Navigation/Link',
|
||||
component: Link,
|
||||
argTypes: {
|
||||
tone: {
|
||||
control: 'select',
|
||||
options: ['default', 'secondary', 'positive', 'negative', 'muted'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Link>;
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
href: '#',
|
||||
children: 'Перейти к котировкам',
|
||||
},
|
||||
};
|
||||
|
||||
export const External: Story = {
|
||||
args: {
|
||||
href: 'https://www.moex.com',
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
children: 'Московская Биржа',
|
||||
},
|
||||
};
|
||||
|
||||
export const Positive: Story = {
|
||||
args: {
|
||||
href: '#',
|
||||
tone: 'positive',
|
||||
children: 'Рост акций',
|
||||
},
|
||||
};
|
||||
|
||||
export const Negative: Story = {
|
||||
args: {
|
||||
href: '#',
|
||||
tone: 'negative',
|
||||
children: 'Падение индекса',
|
||||
},
|
||||
};
|
||||
|
||||
export const AllTones: Story = {
|
||||
render: () => (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||
<Link href="#" tone="default">
|
||||
Основная ссылка
|
||||
</Link>
|
||||
<Link href="#" tone="secondary">
|
||||
Второстепенная ссылка
|
||||
</Link>
|
||||
<Link href="#" tone="positive">
|
||||
Рост +5.2%
|
||||
</Link>
|
||||
<Link href="#" tone="negative">
|
||||
Падение -2.1%
|
||||
</Link>
|
||||
<Link href="#" tone="muted">
|
||||
Неактивная ссылка
|
||||
</Link>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
53
packages/design-system/src/components/Link/Link.test.tsx
Normal file
53
packages/design-system/src/components/Link/Link.test.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Link } from './Link';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Link', () => {
|
||||
it('renders children', () => {
|
||||
renderWithTheme(<Link href="/test">Click</Link>);
|
||||
expect(screen.getByText('Click')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders as an anchor with href', () => {
|
||||
renderWithTheme(<Link href="/test">Click</Link>);
|
||||
const el = screen.getByText('Click');
|
||||
expect(el.tagName).toBe('A');
|
||||
expect(el).toHaveAttribute('href', '/test');
|
||||
});
|
||||
|
||||
it('does not pass color prop to DOM', () => {
|
||||
renderWithTheme(
|
||||
<Link href="/test" tone="positive">
|
||||
Click
|
||||
</Link>,
|
||||
);
|
||||
const el = screen.getByText('Click');
|
||||
expect(el).not.toHaveAttribute('color');
|
||||
});
|
||||
|
||||
it('accepts className and style props', () => {
|
||||
renderWithTheme(
|
||||
<Link href="/test" className="custom" style={{ margin: 4 }}>
|
||||
Click
|
||||
</Link>,
|
||||
);
|
||||
const el = screen.getByText('Click');
|
||||
expect(el.classList.contains('custom')).toBe(true);
|
||||
});
|
||||
|
||||
it('renders with target and rel for external links', () => {
|
||||
renderWithTheme(
|
||||
<Link href="https://example.com" target="_blank" rel="noopener">
|
||||
External
|
||||
</Link>,
|
||||
);
|
||||
const el = screen.getByText('External');
|
||||
expect(el).toHaveAttribute('target', '_blank');
|
||||
expect(el).toHaveAttribute('rel', 'noopener');
|
||||
});
|
||||
});
|
||||
25
packages/design-system/src/components/Link/Link.tsx
Normal file
25
packages/design-system/src/components/Link/Link.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import { Link as MuiLink, type LinkProps as MuiLinkProps } from '@mui/material';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
type Tone = 'default' | 'secondary' | 'positive' | 'negative' | 'muted';
|
||||
|
||||
export interface LinkProps extends Omit<MuiLinkProps, 'variant' | 'color' | 'sx'> {
|
||||
tone?: Tone;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
const TONE_MAP: Record<Tone, MuiLinkProps['color']> = {
|
||||
default: 'primary',
|
||||
secondary: 'text.secondary',
|
||||
positive: 'success.main',
|
||||
negative: 'error.main',
|
||||
muted: 'text.disabled',
|
||||
};
|
||||
|
||||
export function Link({ tone = 'default', children, ...props }: LinkProps) {
|
||||
return (
|
||||
<MuiLink color={TONE_MAP[tone]} {...props}>
|
||||
{children}
|
||||
</MuiLink>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/Link/index.ts
Normal file
2
packages/design-system/src/components/Link/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Link } from './Link';
|
||||
export type { LinkProps } from './Link';
|
||||
@ -0,0 +1,41 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { LoadingState } from './LoadingState';
|
||||
|
||||
const meta: Meta<typeof LoadingState> = {
|
||||
title: 'Feedback/LoadingState',
|
||||
component: LoadingState,
|
||||
argTypes: {
|
||||
title: { control: 'text' },
|
||||
label: { control: 'text' },
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['section', 'page'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof LoadingState>;
|
||||
|
||||
export const Section: Story = {
|
||||
args: {
|
||||
title: 'Loading data',
|
||||
size: 'section',
|
||||
},
|
||||
};
|
||||
|
||||
export const Page: Story = {
|
||||
args: {
|
||||
title: 'Loading your portfolio',
|
||||
label: 'Please wait while we fetch your data',
|
||||
size: 'page',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithLabel: Story = {
|
||||
args: {
|
||||
title: 'Processing',
|
||||
label: 'This may take a moment',
|
||||
size: 'section',
|
||||
},
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { LoadingState } from './LoadingState';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('LoadingState', () => {
|
||||
it('has aria-live polite', () => {
|
||||
renderWithTheme(<LoadingState title="Loading data" />);
|
||||
const region = screen.getByRole('region');
|
||||
expect(region).toHaveAttribute('aria-live', 'polite');
|
||||
});
|
||||
|
||||
it('renders title', () => {
|
||||
renderWithTheme(<LoadingState title="Loading data" />);
|
||||
expect(screen.getByText('Loading data')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders label', () => {
|
||||
renderWithTheme(<LoadingState title="Loading" label="Fetching portfolio" />);
|
||||
expect(screen.getByText('Fetching portfolio')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with section size by default', () => {
|
||||
renderWithTheme(<LoadingState title="Loading" />);
|
||||
const region = screen.getByRole('region');
|
||||
expect(region).toHaveAttribute('data-size', 'section');
|
||||
});
|
||||
|
||||
it('renders with page size', () => {
|
||||
renderWithTheme(<LoadingState title="Loading" size="page" />);
|
||||
const region = screen.getByRole('region');
|
||||
expect(region).toHaveAttribute('data-size', 'page');
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,30 @@
|
||||
import { Box, Stack, CircularProgress } from '@mui/material';
|
||||
import { Heading } from '../Heading';
|
||||
import { Text } from '../Text';
|
||||
|
||||
export interface LoadingStateProps {
|
||||
title: string;
|
||||
label?: string;
|
||||
size?: 'section' | 'page';
|
||||
}
|
||||
|
||||
export function LoadingState({ title, label, size = 'section' }: LoadingStateProps) {
|
||||
const spacing = size === 'page' ? 3 : 2;
|
||||
const progressSize = size === 'page' ? 48 : 32;
|
||||
|
||||
return (
|
||||
<Box
|
||||
role="region"
|
||||
aria-live="polite"
|
||||
aria-label={title}
|
||||
data-size={size}
|
||||
data-testid="loading-state"
|
||||
>
|
||||
<Stack spacing={spacing} alignItems="center">
|
||||
<CircularProgress size={progressSize} />
|
||||
<Heading level={2}>{title}</Heading>
|
||||
{label && <Text>{label}</Text>}
|
||||
</Stack>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { LoadingState } from './LoadingState';
|
||||
export type { LoadingStateProps } from './LoadingState';
|
||||
@ -0,0 +1,39 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Metric } from './Metric';
|
||||
import { PriceChange } from '../PriceChange';
|
||||
|
||||
const meta: Meta<typeof Metric> = {
|
||||
title: 'Data/Metric',
|
||||
component: Metric,
|
||||
argTypes: {
|
||||
label: { control: 'text' },
|
||||
value: { control: 'text' },
|
||||
supportingText: { control: 'text' },
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Metric>;
|
||||
|
||||
export const Basic: Story = {
|
||||
args: {
|
||||
label: 'Общая стоимость',
|
||||
value: '1 234 567 ₽',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithTrend: Story = {
|
||||
args: {
|
||||
label: 'Доходность',
|
||||
value: '1 234 567 ₽',
|
||||
trend: <PriceChange value={5.5} />,
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSupportingText: Story = {
|
||||
args: {
|
||||
label: 'Дивиденды',
|
||||
value: '45 000 ₽',
|
||||
supportingText: 'За последний квартал',
|
||||
},
|
||||
};
|
||||
26
packages/design-system/src/components/Metric/Metric.test.tsx
Normal file
26
packages/design-system/src/components/Metric/Metric.test.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Metric } from './Metric';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Metric', () => {
|
||||
it('renders label and value', () => {
|
||||
renderWithTheme(<Metric label="Total" value="$1,000" />);
|
||||
expect(screen.getByText('Total')).toBeInTheDocument();
|
||||
expect(screen.getByText('$1,000')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders supportingText', () => {
|
||||
renderWithTheme(<Metric label="Total" value="$1,000" supportingText="Since last month" />);
|
||||
expect(screen.getByText('Since last month')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders trend slot', () => {
|
||||
renderWithTheme(<Metric label="Total" value="$1,000" trend={<span>+5%</span>} />);
|
||||
expect(screen.getByText('+5%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
29
packages/design-system/src/components/Metric/Metric.tsx
Normal file
29
packages/design-system/src/components/Metric/Metric.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Box } from '@mui/material';
|
||||
import { Text } from '../Text';
|
||||
|
||||
export interface MetricProps {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
supportingText?: ReactNode;
|
||||
trend?: ReactNode;
|
||||
}
|
||||
|
||||
export function Metric({ label, value, supportingText, trend }: MetricProps) {
|
||||
return (
|
||||
<Box>
|
||||
<Text variant="label" tone="secondary">
|
||||
{label}
|
||||
</Text>
|
||||
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||
<Text variant="numeric">{value}</Text>
|
||||
{trend && <Box sx={{ display: 'flex', alignItems: 'baseline' }}>{trend}</Box>}
|
||||
</Box>
|
||||
{supportingText && (
|
||||
<Text variant="caption" tone="muted">
|
||||
{supportingText}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/Metric/index.ts
Normal file
2
packages/design-system/src/components/Metric/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { Metric } from './Metric';
|
||||
export type { MetricProps } from './Metric';
|
||||
@ -0,0 +1,52 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react';
|
||||
import { Money } from './Money';
|
||||
|
||||
const meta: Meta<typeof Money> = {
|
||||
title: 'Data/Money',
|
||||
component: Money,
|
||||
argTypes: {
|
||||
value: { control: 'number' },
|
||||
currency: { control: 'text' },
|
||||
locale: { control: 'text' },
|
||||
signDisplay: {
|
||||
control: 'select',
|
||||
options: ['auto', 'always', 'never'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof Money>;
|
||||
|
||||
export const Rubles: Story = {
|
||||
args: {
|
||||
value: 1234.56,
|
||||
currency: 'RUB',
|
||||
locale: 'ru-RU',
|
||||
},
|
||||
};
|
||||
|
||||
export const Dollars: Story = {
|
||||
args: {
|
||||
value: 1234.56,
|
||||
currency: 'USD',
|
||||
locale: 'en-US',
|
||||
},
|
||||
};
|
||||
|
||||
export const Negative: Story = {
|
||||
args: {
|
||||
value: -500,
|
||||
currency: 'RUB',
|
||||
locale: 'ru-RU',
|
||||
},
|
||||
};
|
||||
|
||||
export const WithSign: Story = {
|
||||
args: {
|
||||
value: 500,
|
||||
currency: 'RUB',
|
||||
locale: 'ru-RU',
|
||||
signDisplay: 'always',
|
||||
},
|
||||
};
|
||||
35
packages/design-system/src/components/Money/Money.test.tsx
Normal file
35
packages/design-system/src/components/Money/Money.test.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { Money } from './Money';
|
||||
import { MoexVibeThemeProvider } from '../../theme';
|
||||
|
||||
function renderWithTheme(element: React.ReactElement) {
|
||||
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||
}
|
||||
|
||||
describe('Money', () => {
|
||||
it('formats with default locale and currency', () => {
|
||||
renderWithTheme(<Money value={1000} />);
|
||||
expect(screen.getByText('1 000,00 ₽')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats with custom currency', () => {
|
||||
renderWithTheme(<Money value={1000} currency="USD" />);
|
||||
expect(screen.getByText('1 000,00 $')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats with custom locale', () => {
|
||||
renderWithTheme(<Money value={1000} locale="en-US" />);
|
||||
expect(screen.getByText('RUB 1,000.00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows sign with signDisplay always', () => {
|
||||
renderWithTheme(<Money value={500} signDisplay="always" />);
|
||||
expect(screen.getByText('+500,00 ₽')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides sign with signDisplay never', () => {
|
||||
renderWithTheme(<Money value={-500} signDisplay="never" />);
|
||||
expect(screen.getByText('500,00 ₽')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
23
packages/design-system/src/components/Money/Money.tsx
Normal file
23
packages/design-system/src/components/Money/Money.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface MoneyProps {
|
||||
value: number;
|
||||
currency?: string;
|
||||
locale?: string;
|
||||
signDisplay?: 'auto' | 'always' | 'never';
|
||||
}
|
||||
|
||||
export function Money({
|
||||
value,
|
||||
currency = 'RUB',
|
||||
locale = 'ru-RU',
|
||||
signDisplay = 'auto',
|
||||
}: MoneyProps) {
|
||||
const formatted = new Intl.NumberFormat(locale, {
|
||||
style: 'currency',
|
||||
currency,
|
||||
signDisplay,
|
||||
}).format(value);
|
||||
|
||||
return formatted as unknown as ReactNode;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user