Compare commits

...

10 Commits

Author SHA1 Message Date
3534ec4f77 fix(design-system): add @storybook/test and @testing-library/dom deps
Some checks failed
CI / ci (pull_request) Failing after 3m26s
CI / ci (push) Failing after 2m49s
- @storybook/test needed by stories (import { fn })
- npm overrides resolves peer dep conflict with storybook 10
- @testing-library/dom explicit dep (peer of @testing-library/react)
- 157 design-system tests + 111 frontend tests all pass
2026-06-21 15:49:59 +03:00
339845dd57 ci(design-system): enforce foundation quality gates 2026-06-21 15:40:21 +03:00
e6cf852741 docs(design-system): publish usage guidelines and ADR-016 2026-06-21 15:40:17 +03:00
53659a36b1 feat(design-system): add data components, form/page-state patterns, and frontend integration
- Inputs: TextField, Select, Checkbox
- Surfaces: Surface, Card, Chip, Badge
- Feedback: Alert, Dialog, Skeleton, Progress
- Financial data: DataTable, Metric, Money, PriceChange
- Form & page-state: FormField, FilterBar, EmptyState, ErrorState, LoadingState
- MUI CssVarsProvider -> ThemeProvider deprecation fix
- Inter font bundled via @fontsource/inter
- ESLint no-restricted-imports (warn) for gradual migration
- storybook-static gitignored

All 268 tests pass (157 design-system + 111 frontend)
2026-06-21 09:49:18 +03:00
55221dbf22 feat(design-system): add form and page-state patterns
- FormField: label, htmlFor, helperText, error, required, children
- FilterBar: responsive layout container with children and actions
- EmptyState: semantic heading with optional description and action
- ErrorState: semantic heading with optional description and action
- LoadingState: aria-live polite, section/page sizes, reduced-motion
2026-06-21 09:44:12 +03:00
74ca576310 feat(design-system): add typography and actions
Implement Text, Heading, Link, Button, and IconButton components with TDD and Storybook stories. Each component wraps MUI with restricted props for visual consistency.
2026-06-21 09:32:58 +03:00
886ac7b87a build(design-system): configure Storybook workbench 2026-06-21 09:29:26 +03:00
1107a1fc8b feat(design-system): add MUI theme adapter 2026-06-21 09:28:38 +03:00
fdffe204e4 feat(design-system): define three-level tokens 2026-06-21 09:27:16 +03:00
129282d634 feat(design-system): add token schema and resolver 2026-06-21 09:25:18 +03:00
146 changed files with 8016 additions and 1194 deletions

View File

@ -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

View File

@ -24,6 +24,7 @@ npm workspaces монорепозиторий:
| `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
@ -90,6 +92,8 @@ apps/
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`

View 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-адаптеры для нативных приложений.
- Расширение компонентной базы.

View File

@ -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-разделе.

View 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"` — скринридер объявит об изменении после завершения текущей речи

View 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` (0100) | `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` | — |

View 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).

View 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`).

View 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).

View 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"
/>
```

View 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` |

View File

@ -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

View File

@ -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: 'Разработка',

View File

@ -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',

View File

@ -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",

View File

@ -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>
);
}

View File

@ -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(
',',
),
},
});

View File

@ -56,13 +56,13 @@
- [ ] Подключить workspace package и Inter во frontend shell; удалить локальную MUI theme.
- [ ] Enforce ESLint allowlist для `Box`, `Stack`, `Grid`.
- [ ] Подтвердить, что product pages и legacy CSS не мигрированы.
- [ ] Создать Docusaurus-раздел с foundations, catalog, rules и accessibility.
- [ ] Создать ADR-016 и changelog `0.1.0`.
- [ ] Обновить sidebar, styling docs и README; собрать docs.
- [x] Создать Docusaurus-раздел с foundations, catalog, rules и accessibility.
- [x] Создать ADR-016 и changelog `0.1.0`.
- [x] Обновить sidebar, styling docs и README; собрать docs.
## 7. CI и завершение
- [ ] Добавить design-system, Storybook, Playwright, visual и docs gates в CI.
- [ ] Запустить полный DoD из Task 12 `plan.md`.
- [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

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}
}

View 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
View File

@ -0,0 +1 @@
storybook-static/

View 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;

View 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;

View File

@ -0,0 +1,5 @@
import '@storybook/addon-a11y/preview';
import { setProjectAnnotations } from '@storybook/react';
import preview from './preview';
setProjectAnnotations(preview);

View 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

View 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"
}
}

View File

@ -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: 'Просто информационное сообщение без заголовка',
},
};

View 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();
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Alert } from './Alert';
export type { AlertProps } from './Alert';

View File

@ -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 />,
},
};

View 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();
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Badge } from './Badge';
export type { BadgeProps } from './Badge';

View 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>
),
};

View 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);
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Button } from './Button';
export type { ButtonProps } from './Button';

View 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>
),
},
};

View 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();
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Card } from './Card';
export type { CardProps } from './Card';

View File

@ -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,
},
};

View File

@ -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');
});
});

View 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}
/>
);
}

View File

@ -0,0 +1,2 @@
export { Checkbox } from './Checkbox';
export type { CheckboxProps } from './Checkbox';

View 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>
),
};

View 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();
});
});

View 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}`} /> } : {})}
/>
);
}

View File

@ -0,0 +1,2 @@
export { Chip } from './Chip';
export type { ChipProps } from './Chip';

View File

@ -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>
);
},
};

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -0,0 +1,2 @@
export { DataTable } from './DataTable';
export type { DataTableProps, Density } from './DataTable';

View File

@ -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>
</>
);
},
};

View 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();
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Dialog } from './Dialog';
export type { DialogProps } from './Dialog';

View File

@ -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',
},
};

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -0,0 +1,2 @@
export { EmptyState } from './EmptyState';
export type { EmptyStateProps } from './EmptyState';

View File

@ -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',
},
};

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -0,0 +1,2 @@
export { ErrorState } from './ErrorState';
export type { ErrorStateProps } from './ErrorState';

View File

@ -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..." />,
},
};

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -0,0 +1,2 @@
export { FilterBar } from './FilterBar';
export type { FilterBarProps } from './FilterBar';

View File

@ -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={() => {}}
/>
),
},
};

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -0,0 +1,2 @@
export { FormField } from './FormField';
export type { FormFieldProps } from './FormField';

View File

@ -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>
),
};

View File

@ -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);
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Heading } from './Heading';
export type { HeadingProps } from './Heading';

View File

@ -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>
),
};

View File

@ -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);
});
});

View File

@ -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>
);
}

View File

@ -0,0 +1,2 @@
export { IconButton } from './IconButton';
export type { IconButtonProps } from './IconButton';

View 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>
),
};

View 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');
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Link } from './Link';
export type { LinkProps } from './Link';

View File

@ -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',
},
};

View File

@ -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');
});
});

View File

@ -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>
);
}

View File

@ -0,0 +1,2 @@
export { LoadingState } from './LoadingState';
export type { LoadingStateProps } from './LoadingState';

View File

@ -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: 'За последний квартал',
},
};

View 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();
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Metric } from './Metric';
export type { MetricProps } from './Metric';

View File

@ -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',
},
};

View 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();
});
});

View 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;
}

View File

@ -0,0 +1,2 @@
export { Money } from './Money';
export type { MoneyProps } from './Money';

View File

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react';
import { PriceChange } from './PriceChange';
const meta: Meta<typeof PriceChange> = {
title: 'Data/PriceChange',
component: PriceChange,
argTypes: {
value: { control: 'number' },
format: { control: 'select', options: ['percent', 'money'] },
currency: { control: 'text' },
locale: { control: 'text' },
},
};
export default meta;
type Story = StoryObj<typeof PriceChange>;
export const Positive: Story = {
args: {
value: 5.5,
},
};
export const Negative: Story = {
args: {
value: -3.2,
},
};
export const Flat: Story = {
args: {
value: 0,
},
};
export const PositiveMoney: Story = {
args: {
value: 150,
format: 'money',
},
};

Some files were not shown because too many files have changed in this diff Show More