diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 13b9c91..fa9f736 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -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 diff --git a/README.md b/README.md index dce1318..379051f 100644 --- a/README.md +++ b/README.md @@ -21,9 +21,10 @@ npm workspaces монорепозиторий: | Пакет | Назначение | | --------------- | -------------------------------------------------- | -| `apps/backend` | NestJS API (единственная точка доступа к MOEX ISS) | -| `apps/frontend` | React SPA на Vite | -| `apps/docs` | Сайт документации Docusaurus | +| `apps/backend` | NestJS API (единственная точка доступа к MOEX ISS) | +| `apps/frontend` | React SPA на Vite | +| `apps/docs` | Сайт документации Docusaurus | +| `packages/design-system` | Дизайн-система (Storybook, MUI-адаптер, UI-компоненты) | --- @@ -31,6 +32,7 @@ npm workspaces монорепозиторий: - **Бэкенд:** NestJS, TypeScript, OpenAPI (Swagger) - **Фронтенд:** React, TypeScript, Vite, TanStack Query, lightweight-charts +- **Дизайн-система:** MUI v7, Storybook 10, lightweight-charts - **Документация:** Docusaurus - **Инфраструктура:** Docker, docker-compose @@ -87,9 +89,11 @@ npm run test:integration -w apps/backend ``` apps/ - backend/ — NestJS API, единая точка доступа к MOEX ISS - frontend/ — React SPA на Vite - docs/ — сайт документации Docusaurus + backend/ — NestJS API, единая точка доступа к MOEX ISS + frontend/ — React SPA на Vite + docs/ — сайт документации Docusaurus +packages/ + design-system/ — дизайн-система (MUI-адаптер, UI-компоненты, Storybook) docs/ features/ — спецификации и планы реализации (SDD) epics/ — продуктовые эпики @@ -105,16 +109,24 @@ docs/ | ---------------------------------- | --------------------------------------------------------------------------- | | `npm run dev:backend` | Запуск NestJS в режиме watch на :3000 | | `npm run dev:frontend` | Vite dev-сервер на :5173, проксирует `/api` → :3000 | -| `npm run dev:docs` | Docusaurus dev-сервер | +| `npm run dev:docs` | Docusaurus dev-сервер (опубликованная документация) | | `npm run build:backend` | `nest build` | | `npm run build:frontend` | `tsc -b && vite build` (в две фазы) | | `npm run build:docs` | `docusaurus build` | +| `npm run build:design-system` | Сборка дизайн-системы (`tsc`) | +| `npm run build:storybook` | Статическая сборка Storybook | | `npm run test:backend` | `vitest run` (SWC, не ts-jest) | | `npm run test:frontend` | Frontend Vitest suite | -| `npm run lint` | ESLint для backend и frontend | +| `npm run test:design-system` | Unit-тесты дизайн-системы (Vitest) | +| `npm run test:storybook` | Браузерные тесты Storybook (Vitest browser mode + Playwright) | +| `npm run storybook` | Storybook dev-сервер на :6006 (инженерный workbench, не docs) | +| `npm run lint` | ESLint для backend, frontend и design-system | +| `npm run lint:design-system` | ESLint для дизайн-системы | | `npm run format` | Prettier для всех `*.{ts,tsx}` | | `npm run codegen -w apps/frontend` | `openapi-typescript` из запущенного локального Swagger → `src/api/types.ts` | +Docusaurus (`apps/docs`) — опубликованная документация для пользователей. Storybook (`packages/design-system`) — инженерный workbench для разработки компонентов. + Интеграционные тесты с MOEX: `npm run test:integration -w apps/backend`. Один backend-тест: `npm exec -w apps/backend -- vitest run src/path/to/test.spec.ts` diff --git a/apps/docs/docs/adr/ADR-016-design-system.md b/apps/docs/docs/adr/ADR-016-design-system.md new file mode 100644 index 0000000..8402226 --- /dev/null +++ b/apps/docs/docs/adr/ADR-016-design-system.md @@ -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-адаптеры для нативных приложений. +- Расширение компонентной базы. diff --git a/apps/docs/docs/adr/index.md b/apps/docs/docs/adr/index.md index 66378c7..ac259c5 100644 --- a/apps/docs/docs/adr/index.md +++ b/apps/docs/docs/adr/index.md @@ -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-разделе. diff --git a/apps/docs/docs/design-system/accessibility.md b/apps/docs/docs/design-system/accessibility.md new file mode 100644 index 0000000..e4f104f --- /dev/null +++ b/apps/docs/docs/design-system/accessibility.md @@ -0,0 +1,49 @@ +# Доступность (Accessibility) + +## Целевой уровень + +WCAG 2.2 AA. + +## Требования к компонентам + +### IconButton + +**Требует `label`** — свойство `aria-label` обязательно для всех иконок без текста. + +```tsx + + + +``` + +### Dialog + +- **Требует `title`** — заголовок, используемый как `aria-labelledby` +- Содержит focus trap (фокус не покидает диалог) +- Закрывается по Escape + +### Progress + +- `indeterminate`-режим выставляет `aria-busy` на контейнере + +### PriceChange + +- Использует **текстовый индикатор направления** (`+`, `−`, `—`), а не только цвет +- Скринридеры получают знак перед числом + +### DataTable + +- **Требует `caption`** — описание таблицы для скринридеров +- Семантические заголовки через `` + +### Skeleton + +- `aria-hidden` по умолчанию — скелетоны не должны озвучиваться + +### Alert + +- Использует `role="alert"` для немедленного объявления скринридером + +### LoadingState + +- `aria-live="polite"` — скринридер объявит об изменении после завершения текущей речи diff --git a/apps/docs/docs/design-system/components.md b/apps/docs/docs/design-system/components.md new file mode 100644 index 0000000..73b98cb --- /dev/null +++ b/apps/docs/docs/design-system/components.md @@ -0,0 +1,259 @@ +# Компоненты + +## Матрица «задача → компонент» + +| Задача | Рекомендуемый компонент | Не использовать | +|--------|------------------------|-----------------| +| Отображение текста | Text, Heading | MUI Typography напрямую | +| Ссылка | Link | MUI Link напрямую | +| Действие | Button, IconButton | MUI Button напрямую | +| Ввод текста | TextField | MUI TextField напрямую | +| Выбор из списка | Select | MUI Select напрямую | +| Выбор опции | Checkbox | MUI Checkbox напрямую | +| Контейнер/фон | Surface | MUI Paper напрямую | +| Карточка | Card | MUI Card напрямую | +| Ярлык/тег | Chip | MUI Chip напрямую | +| Бейдж | Badge | MUI Badge напрямую | +| Уведомление | Alert | MUI Alert напрямую | +| Модальное окно | Dialog | MUI Dialog напрямую | +| Загрузка скелета | Skeleton | MUI Skeleton напрямую | +| Индикатор прогресса | Progress | MUI CircularProgress / LinearProgress напрямую | +| Финансовое значение | Money, Metric, PriceChange | сырое число | +| Таблица | DataTable | MUI Table напрямую | +| Поле формы | FormField | ручная связка label + helperText | +| Панель фильтров | FilterBar | ручная вёрстка фильтров | +| Пустое состояние | EmptyState | ручная вёрстка | +| Ошибка | ErrorState | ручная вёрстка | +| Загрузка | LoadingState | ручная вёрстка | + +## Каталог компонентов + +### Text + +Отображение текста с заданным семантическим размером. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `variant` | `'body1' \| 'body2' \| 'caption' \| 'overline'` | `'body1'` | +| `color` | `'primary' \| 'secondary' \| 'disabled' \| 'inverse'` | `'primary'` | + +### Heading + +Заголовок. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `variant` | `'h1' \| 'h2' \| 'h3' \| 'h4' \| 'h5' \| 'h6'` | `'h3'` | + +### Link + +Ссылка. Под капотом MUI Link с цветом `color.action.primary`. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `href` | `string` | — | +| `underline` | `'none' \| 'hover' \| 'always'` | `'hover'` | + +### Button + +Кнопка действия. Обёртка MUI Button. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `variant` | `'primary' \| 'secondary' \| 'danger'` | `'primary'` | +| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | +| `loading` | `boolean` | `false` | + +### IconButton + +Кнопка-иконка. **Требует `label`** (aria-label). + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `label` | `string` | — | +| `size` | `'sm' \| 'md'` | `'md'` | + +### TextField + +Текстовое поле ввода. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `size` | `'sm' \| 'md'` | `'md'` | +| `fullWidth` | `boolean` | `true` | + +### Select + +Выпадающий список. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `size` | `'sm' \| 'md'` | `'md'` | +| `fullWidth` | `boolean` | `true` | + +### Checkbox + +Чекбокс. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `label` | `string` | — | + +### Surface + +Базовый контейнер с фоном и скруглением. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `variant` | `'default' \| 'subtle' \| 'raised'` | `'default'` | + +### Card + +Карточка. Поверхность с тенью. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `padding` | `'sm' \| 'md' \| 'lg'` | `'md'` | + +### Chip + +Компактный элемент для отображения тега или статуса. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `variant` | `'filled' \| 'outlined'` | `'filled'` | +| `color` | `'default' \| 'info' \| 'success' \| 'warning' \| 'error'` | `'default'` | + +### Badge + +Бейдж с числовым или текстовым значением. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `variant` | `'dot' \| 'standard'` | `'standard'` | +| `color` | `'info' \| 'success' \| 'warning' \| 'error'` | `'info'` | + +### Alert + +Уведомление. Использует `role="alert"`. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `severity` | `'info' \| 'success' \| 'warning' \| 'error'` | `'info'` | +| `title` | `string` | — | + +### Dialog + +Модальное окно. **Требует `title`**, содержит focus trap, закрывается по Escape. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `open` | `boolean` | — | +| `title` | `string` | — | +| `onClose` | `() => void` | — | + +### Skeleton + +Скелетон загрузки. `aria-hidden` по умолчанию. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `width` | `string \| number` | `'100%'` | +| `height` | `string \| number` | `20` | + +### Progress + +Индикатор прогресса. Поддерживает indeterminate-режим с `aria-busy`. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `variant` | `'determinate' \| 'indeterminate'` | `'indeterminate'` | +| `value` | `number` (0–100) | `0` | + +### DataTable + +Таблица данных на основе TanStack Table. + +| Свойство | Тип | По умолчанию | +|----------|-----|--------------| +| `columns` | `ColumnDef[]` | — | +| `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` | — | diff --git a/apps/docs/docs/design-system/foundations.md b/apps/docs/docs/design-system/foundations.md new file mode 100644 index 0000000..62703dd --- /dev/null +++ b/apps/docs/docs/design-system/foundations.md @@ -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). diff --git a/apps/docs/docs/design-system/governance.md b/apps/docs/docs/design-system/governance.md new file mode 100644 index 0000000..4f7045b --- /dev/null +++ b/apps/docs/docs/design-system/governance.md @@ -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`). diff --git a/apps/docs/docs/design-system/overview.md b/apps/docs/docs/design-system/overview.md new file mode 100644 index 0000000..fb92c7c --- /dev/null +++ b/apps/docs/docs/design-system/overview.md @@ -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). diff --git a/apps/docs/docs/design-system/patterns.md b/apps/docs/docs/design-system/patterns.md new file mode 100644 index 0000000..ecc7418 --- /dev/null +++ b/apps/docs/docs/design-system/patterns.md @@ -0,0 +1,94 @@ +# Паттерны + +## Финансовые данные + +### Money + +Форматирование через `Intl.NumberFormat`: + +- Локаль: `ru-RU` +- Валюта по умолчанию: `RUB` + +```tsx + // 1 234,50 ₽ + // 1 234,50 $ +``` + +### PriceChange + +Отображает изменение цены. Обязательное правило: **никогда не использовать только цвет**. Всегда добавлять текстовый индикатор направления (`+`, `−`, `—`). + +| Направление | Индикатор | Цвет токена | +|-------------|-----------|-------------| +| Рост | `+` | `color.finance.positive` | +| Падение | `−` | `color.finance.negative` | +| Нейтрально | `—` | `color.finance.neutral` | + +### Metric + +Композитный компонент: label + value + опционально supporting (доп. текст) и trend (направление). + +```tsx +} + trend="up" +/> +``` + +## Формы + +### FormField + +Обёртка для поля, связывающая label, helperText и error через ассоциацию `htmlFor`/`id`. + +```tsx + + + +``` + +### FilterBar + +Responsive-панель фильтров с автоматическим переносом (wrap). + +```tsx + + {}} + /> + + ), + actions: , + }, +}; + +export const NoActions: Story = { + args: { + children: , + }, +}; diff --git a/packages/design-system/src/components/FilterBar/FilterBar.test.tsx b/packages/design-system/src/components/FilterBar/FilterBar.test.tsx new file mode 100644 index 0000000..1af1318 --- /dev/null +++ b/packages/design-system/src/components/FilterBar/FilterBar.test.tsx @@ -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({element}); +} + +describe('FilterBar', () => { + it('renders children', () => { + renderWithTheme( + + + , + ); + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + it('renders actions', () => { + renderWithTheme( + Apply}> + + , + ); + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/FilterBar/FilterBar.tsx b/packages/design-system/src/components/FilterBar/FilterBar.tsx new file mode 100644 index 0000000..c1ba772 --- /dev/null +++ b/packages/design-system/src/components/FilterBar/FilterBar.tsx @@ -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 ( + + + {children} + + {actions && ( + + {actions} + + )} + + ); +} diff --git a/packages/design-system/src/components/FilterBar/index.ts b/packages/design-system/src/components/FilterBar/index.ts new file mode 100644 index 0000000..38debcb --- /dev/null +++ b/packages/design-system/src/components/FilterBar/index.ts @@ -0,0 +1,2 @@ +export { FilterBar } from './FilterBar'; +export type { FilterBarProps } from './FilterBar'; diff --git a/packages/design-system/src/components/FormField/FormField.stories.tsx b/packages/design-system/src/components/FormField/FormField.stories.tsx new file mode 100644 index 0000000..257d88e --- /dev/null +++ b/packages/design-system/src/components/FormField/FormField.stories.tsx @@ -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 = { + title: 'Form/FormField', + component: FormField, + argTypes: { + label: { control: 'text' }, + helperText: { control: 'text' }, + error: { control: 'text' }, + required: { control: 'boolean' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithTextField: Story = { + args: { + label: 'Username', + htmlFor: 'username', + helperText: 'Enter your username', + children: , + }, +}; + +export const Required: Story = { + args: { + label: 'Email', + htmlFor: 'email', + required: true, + helperText: 'We will never share your email', + children: , + }, +}; + +export const WithError: Story = { + args: { + label: 'Name', + htmlFor: 'name', + error: 'Name is required', + children: , + }, +}; + +export const WithSelect: Story = { + args: { + label: 'Country', + htmlFor: 'country', + children: ( + + , + ); + const label = screen.getByText('Username'); + expect(label.tagName).toBe('LABEL'); + expect(label).toHaveAttribute('for', 'username'); + }); + + it('renders helper text', () => { + renderWithTheme( + + + , + ); + expect(screen.getByText('Enter your email')).toBeInTheDocument(); + }); + + it('renders error text and associates with input', () => { + renderWithTheme( + + + , + ); + expect(screen.getByText('Name is required')).toBeInTheDocument(); + }); + + it('renders required indicator', () => { + renderWithTheme( + + + , + ); + expect(screen.getByText('*')).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/FormField/FormField.tsx b/packages/design-system/src/components/FormField/FormField.tsx new file mode 100644 index 0000000..a329bfc --- /dev/null +++ b/packages/design-system/src/components/FormField/FormField.tsx @@ -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 ( + + {label} + {children} + {(helperText || error) && {error || helperText}} + + ); +} diff --git a/packages/design-system/src/components/FormField/index.ts b/packages/design-system/src/components/FormField/index.ts new file mode 100644 index 0000000..269f0c7 --- /dev/null +++ b/packages/design-system/src/components/FormField/index.ts @@ -0,0 +1,2 @@ +export { FormField } from './FormField'; +export type { FormFieldProps } from './FormField'; diff --git a/packages/design-system/src/components/Heading/Heading.stories.tsx b/packages/design-system/src/components/Heading/Heading.stories.tsx new file mode 100644 index 0000000..b75a6a2 --- /dev/null +++ b/packages/design-system/src/components/Heading/Heading.stories.tsx @@ -0,0 +1,92 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Heading } from './Heading'; + +const meta: Meta = { + 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; + +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: () => ( +
+ + H1 — Заголовок страницы + + + H2 — Раздел + + + H3 — Подраздел + + H4 — Группа + H5 — Элемент + H6 — Мелкий заголовок +
+ ), +}; diff --git a/packages/design-system/src/components/Heading/Heading.test.tsx b/packages/design-system/src/components/Heading/Heading.test.tsx new file mode 100644 index 0000000..cb98adb --- /dev/null +++ b/packages/design-system/src/components/Heading/Heading.test.tsx @@ -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({element}); +} + +describe('Heading', () => { + it('renders children', () => { + renderWithTheme(Title); + expect(screen.getByText('Title')).toBeInTheDocument(); + }); + + it('renders h1 for level 1', () => { + renderWithTheme(H1); + expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument(); + }); + + it('renders h2 for level 2', () => { + renderWithTheme(H2); + expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument(); + }); + + it('renders h3 for level 3', () => { + renderWithTheme(H3); + expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument(); + }); + + it('renders h4 for level 4', () => { + renderWithTheme(H4); + expect(screen.getByRole('heading', { level: 4 })).toBeInTheDocument(); + }); + + it('renders h5 for level 5', () => { + renderWithTheme(H5); + expect(screen.getByRole('heading', { level: 5 })).toBeInTheDocument(); + }); + + it('renders h6 for level 6', () => { + renderWithTheme(H6); + expect(screen.getByRole('heading', { level: 6 })).toBeInTheDocument(); + }); + + it('accepts className and style props', () => { + renderWithTheme( + + Hello + , + ); + const el = screen.getByText('Hello'); + expect(el.classList.contains('custom')).toBe(true); + }); +}); diff --git a/packages/design-system/src/components/Heading/Heading.tsx b/packages/design-system/src/components/Heading/Heading.tsx new file mode 100644 index 0000000..3b7664e --- /dev/null +++ b/packages/design-system/src/components/Heading/Heading.tsx @@ -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 { + level: 1 | 2 | 3 | 4 | 5 | 6; + size?: Size; + children: ReactNode; +} + +const SIZE_VARIANT: Record = { + display: 'h1', + title: 'h2', + section: 'h3', + subsection: 'h4', +}; + +export function Heading({ level, size = 'title', children, ...props }: HeadingProps) { + return ( + + {children} + + ); +} diff --git a/packages/design-system/src/components/Heading/index.ts b/packages/design-system/src/components/Heading/index.ts new file mode 100644 index 0000000..f617246 --- /dev/null +++ b/packages/design-system/src/components/Heading/index.ts @@ -0,0 +1,2 @@ +export { Heading } from './Heading'; +export type { HeadingProps } from './Heading'; diff --git a/packages/design-system/src/components/IconButton/IconButton.stories.tsx b/packages/design-system/src/components/IconButton/IconButton.stories.tsx new file mode 100644 index 0000000..6671912 --- /dev/null +++ b/packages/design-system/src/components/IconButton/IconButton.stories.tsx @@ -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 = { + title: 'Actions/IconButton', + component: IconButton, + argTypes: { + size: { + control: 'select', + options: ['small', 'medium'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Search: Story = { + args: { + label: 'Поиск', + children: , + }, +}; + +export const Close: Story = { + args: { + label: 'Закрыть', + children: , + }, +}; + +export const Delete: Story = { + args: { + label: 'Удалить', + children: , + }, +}; + +export const Settings: Story = { + args: { + label: 'Настройки', + children: , + }, +}; + +export const Small: Story = { + args: { + size: 'small', + label: 'Поиск', + children: , + }, +}; + +export const AllSizes: Story = { + render: () => ( +
+ + + + + + +
+ ), +}; + +export const AllExamples: Story = { + render: () => ( +
+ + + + + + + + + + + + +
+ ), +}; diff --git a/packages/design-system/src/components/IconButton/IconButton.test.tsx b/packages/design-system/src/components/IconButton/IconButton.test.tsx new file mode 100644 index 0000000..45df86e --- /dev/null +++ b/packages/design-system/src/components/IconButton/IconButton.test.tsx @@ -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({element}); +} + +describe('IconButton', () => { + it('renders with aria-label', () => { + renderWithTheme( + + X + , + ); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + }); + + it('renders children', () => { + renderWithTheme( + + + , + ); + expect(screen.getByText('☰')).toBeInTheDocument(); + }); + + it('does not pass color prop to DOM', () => { + renderWithTheme( + + 🔍 + , + ); + const btn = screen.getByRole('button'); + expect(btn).not.toHaveAttribute('color'); + }); + + it('accepts className and style props', () => { + renderWithTheme( + + + , + ); + const btn = screen.getByRole('button'); + expect(btn.classList.contains('custom')).toBe(true); + }); + + it('renders small size', () => { + renderWithTheme( + + S + , + ); + const btn = screen.getByRole('button'); + expect(btn.classList.contains('MuiIconButton-sizeSmall')).toBe(true); + }); + + it('renders medium size by default', () => { + renderWithTheme( + + M + , + ); + const btn = screen.getByRole('button'); + expect(btn.classList.contains('MuiIconButton-sizeMedium')).toBe(true); + }); +}); diff --git a/packages/design-system/src/components/IconButton/IconButton.tsx b/packages/design-system/src/components/IconButton/IconButton.tsx new file mode 100644 index 0000000..d1e2bca --- /dev/null +++ b/packages/design-system/src/components/IconButton/IconButton.tsx @@ -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 ( + + {children} + + ); +} diff --git a/packages/design-system/src/components/IconButton/index.ts b/packages/design-system/src/components/IconButton/index.ts new file mode 100644 index 0000000..5f509c9 --- /dev/null +++ b/packages/design-system/src/components/IconButton/index.ts @@ -0,0 +1,2 @@ +export { IconButton } from './IconButton'; +export type { IconButtonProps } from './IconButton'; diff --git a/packages/design-system/src/components/Link/Link.stories.tsx b/packages/design-system/src/components/Link/Link.stories.tsx new file mode 100644 index 0000000..a41b7f7 --- /dev/null +++ b/packages/design-system/src/components/Link/Link.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Link } from './Link'; + +const meta: Meta = { + title: 'Navigation/Link', + component: Link, + argTypes: { + tone: { + control: 'select', + options: ['default', 'secondary', 'positive', 'negative', 'muted'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +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: () => ( +
+ + Основная ссылка + + + Второстепенная ссылка + + + Рост +5.2% + + + Падение -2.1% + + + Неактивная ссылка + +
+ ), +}; diff --git a/packages/design-system/src/components/Link/Link.test.tsx b/packages/design-system/src/components/Link/Link.test.tsx new file mode 100644 index 0000000..507cae8 --- /dev/null +++ b/packages/design-system/src/components/Link/Link.test.tsx @@ -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({element}); +} + +describe('Link', () => { + it('renders children', () => { + renderWithTheme(Click); + expect(screen.getByText('Click')).toBeInTheDocument(); + }); + + it('renders as an anchor with href', () => { + renderWithTheme(Click); + const el = screen.getByText('Click'); + expect(el.tagName).toBe('A'); + expect(el).toHaveAttribute('href', '/test'); + }); + + it('does not pass color prop to DOM', () => { + renderWithTheme( + + Click + , + ); + const el = screen.getByText('Click'); + expect(el).not.toHaveAttribute('color'); + }); + + it('accepts className and style props', () => { + renderWithTheme( + + Click + , + ); + const el = screen.getByText('Click'); + expect(el.classList.contains('custom')).toBe(true); + }); + + it('renders with target and rel for external links', () => { + renderWithTheme( + + External + , + ); + const el = screen.getByText('External'); + expect(el).toHaveAttribute('target', '_blank'); + expect(el).toHaveAttribute('rel', 'noopener'); + }); +}); diff --git a/packages/design-system/src/components/Link/Link.tsx b/packages/design-system/src/components/Link/Link.tsx new file mode 100644 index 0000000..d784605 --- /dev/null +++ b/packages/design-system/src/components/Link/Link.tsx @@ -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 { + tone?: Tone; + children: ReactNode; +} + +const TONE_MAP: Record = { + default: 'primary', + secondary: 'text.secondary', + positive: 'success.main', + negative: 'error.main', + muted: 'text.disabled', +}; + +export function Link({ tone = 'default', children, ...props }: LinkProps) { + return ( + + {children} + + ); +} diff --git a/packages/design-system/src/components/Link/index.ts b/packages/design-system/src/components/Link/index.ts new file mode 100644 index 0000000..ed48706 --- /dev/null +++ b/packages/design-system/src/components/Link/index.ts @@ -0,0 +1,2 @@ +export { Link } from './Link'; +export type { LinkProps } from './Link'; diff --git a/packages/design-system/src/components/LoadingState/LoadingState.stories.tsx b/packages/design-system/src/components/LoadingState/LoadingState.stories.tsx new file mode 100644 index 0000000..49a3925 --- /dev/null +++ b/packages/design-system/src/components/LoadingState/LoadingState.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { LoadingState } from './LoadingState'; + +const meta: Meta = { + title: 'Feedback/LoadingState', + component: LoadingState, + argTypes: { + title: { control: 'text' }, + label: { control: 'text' }, + size: { + control: 'select', + options: ['section', 'page'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +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', + }, +}; diff --git a/packages/design-system/src/components/LoadingState/LoadingState.test.tsx b/packages/design-system/src/components/LoadingState/LoadingState.test.tsx new file mode 100644 index 0000000..1b0652b --- /dev/null +++ b/packages/design-system/src/components/LoadingState/LoadingState.test.tsx @@ -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({element}); +} + +describe('LoadingState', () => { + it('has aria-live polite', () => { + renderWithTheme(); + const region = screen.getByRole('region'); + expect(region).toHaveAttribute('aria-live', 'polite'); + }); + + it('renders title', () => { + renderWithTheme(); + expect(screen.getByText('Loading data')).toBeInTheDocument(); + }); + + it('renders label', () => { + renderWithTheme(); + expect(screen.getByText('Fetching portfolio')).toBeInTheDocument(); + }); + + it('renders with section size by default', () => { + renderWithTheme(); + const region = screen.getByRole('region'); + expect(region).toHaveAttribute('data-size', 'section'); + }); + + it('renders with page size', () => { + renderWithTheme(); + const region = screen.getByRole('region'); + expect(region).toHaveAttribute('data-size', 'page'); + }); +}); diff --git a/packages/design-system/src/components/LoadingState/LoadingState.tsx b/packages/design-system/src/components/LoadingState/LoadingState.tsx new file mode 100644 index 0000000..df550c9 --- /dev/null +++ b/packages/design-system/src/components/LoadingState/LoadingState.tsx @@ -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 ( + + + + {title} + {label && {label}} + + + ); +} diff --git a/packages/design-system/src/components/LoadingState/index.ts b/packages/design-system/src/components/LoadingState/index.ts new file mode 100644 index 0000000..59024cd --- /dev/null +++ b/packages/design-system/src/components/LoadingState/index.ts @@ -0,0 +1,2 @@ +export { LoadingState } from './LoadingState'; +export type { LoadingStateProps } from './LoadingState'; diff --git a/packages/design-system/src/components/Metric/Metric.stories.tsx b/packages/design-system/src/components/Metric/Metric.stories.tsx new file mode 100644 index 0000000..e9fddc3 --- /dev/null +++ b/packages/design-system/src/components/Metric/Metric.stories.tsx @@ -0,0 +1,39 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Metric } from './Metric'; +import { PriceChange } from '../PriceChange'; + +const meta: Meta = { + title: 'Data/Metric', + component: Metric, + argTypes: { + label: { control: 'text' }, + value: { control: 'text' }, + supportingText: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + label: 'Общая стоимость', + value: '1 234 567 ₽', + }, +}; + +export const WithTrend: Story = { + args: { + label: 'Доходность', + value: '1 234 567 ₽', + trend: , + }, +}; + +export const WithSupportingText: Story = { + args: { + label: 'Дивиденды', + value: '45 000 ₽', + supportingText: 'За последний квартал', + }, +}; diff --git a/packages/design-system/src/components/Metric/Metric.test.tsx b/packages/design-system/src/components/Metric/Metric.test.tsx new file mode 100644 index 0000000..b54d53e --- /dev/null +++ b/packages/design-system/src/components/Metric/Metric.test.tsx @@ -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({element}); +} + +describe('Metric', () => { + it('renders label and value', () => { + renderWithTheme(); + expect(screen.getByText('Total')).toBeInTheDocument(); + expect(screen.getByText('$1,000')).toBeInTheDocument(); + }); + + it('renders supportingText', () => { + renderWithTheme(); + expect(screen.getByText('Since last month')).toBeInTheDocument(); + }); + + it('renders trend slot', () => { + renderWithTheme(+5%} />); + expect(screen.getByText('+5%')).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/Metric/Metric.tsx b/packages/design-system/src/components/Metric/Metric.tsx new file mode 100644 index 0000000..f3ffffa --- /dev/null +++ b/packages/design-system/src/components/Metric/Metric.tsx @@ -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 ( + + + {label} + + + {value} + {trend && {trend}} + + {supportingText && ( + + {supportingText} + + )} + + ); +} diff --git a/packages/design-system/src/components/Metric/index.ts b/packages/design-system/src/components/Metric/index.ts new file mode 100644 index 0000000..45456a1 --- /dev/null +++ b/packages/design-system/src/components/Metric/index.ts @@ -0,0 +1,2 @@ +export { Metric } from './Metric'; +export type { MetricProps } from './Metric'; diff --git a/packages/design-system/src/components/Money/Money.stories.tsx b/packages/design-system/src/components/Money/Money.stories.tsx new file mode 100644 index 0000000..0d3965a --- /dev/null +++ b/packages/design-system/src/components/Money/Money.stories.tsx @@ -0,0 +1,52 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Money } from './Money'; + +const meta: Meta = { + 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; + +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', + }, +}; diff --git a/packages/design-system/src/components/Money/Money.test.tsx b/packages/design-system/src/components/Money/Money.test.tsx new file mode 100644 index 0000000..5abac4d --- /dev/null +++ b/packages/design-system/src/components/Money/Money.test.tsx @@ -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({element}); +} + +describe('Money', () => { + it('formats with default locale and currency', () => { + renderWithTheme(); + expect(screen.getByText('1 000,00 ₽')).toBeInTheDocument(); + }); + + it('formats with custom currency', () => { + renderWithTheme(); + expect(screen.getByText('1 000,00 $')).toBeInTheDocument(); + }); + + it('formats with custom locale', () => { + renderWithTheme(); + expect(screen.getByText('RUB 1,000.00')).toBeInTheDocument(); + }); + + it('shows sign with signDisplay always', () => { + renderWithTheme(); + expect(screen.getByText('+500,00 ₽')).toBeInTheDocument(); + }); + + it('hides sign with signDisplay never', () => { + renderWithTheme(); + expect(screen.getByText('500,00 ₽')).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/Money/Money.tsx b/packages/design-system/src/components/Money/Money.tsx new file mode 100644 index 0000000..be90dc9 --- /dev/null +++ b/packages/design-system/src/components/Money/Money.tsx @@ -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; +} diff --git a/packages/design-system/src/components/Money/index.ts b/packages/design-system/src/components/Money/index.ts new file mode 100644 index 0000000..f16f9cf --- /dev/null +++ b/packages/design-system/src/components/Money/index.ts @@ -0,0 +1,2 @@ +export { Money } from './Money'; +export type { MoneyProps } from './Money'; diff --git a/packages/design-system/src/components/PriceChange/PriceChange.stories.tsx b/packages/design-system/src/components/PriceChange/PriceChange.stories.tsx new file mode 100644 index 0000000..4f42f5a --- /dev/null +++ b/packages/design-system/src/components/PriceChange/PriceChange.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { PriceChange } from './PriceChange'; + +const meta: Meta = { + 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; + +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', + }, +}; diff --git a/packages/design-system/src/components/PriceChange/PriceChange.test.tsx b/packages/design-system/src/components/PriceChange/PriceChange.test.tsx new file mode 100644 index 0000000..235ef4e --- /dev/null +++ b/packages/design-system/src/components/PriceChange/PriceChange.test.tsx @@ -0,0 +1,38 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { PriceChange } from './PriceChange'; +import { MoexVibeThemeProvider } from '../../theme'; + +function renderWithTheme(element: React.ReactElement) { + return render({element}); +} + +describe('PriceChange', () => { + it('shows up indicator for positive value', () => { + renderWithTheme(); + expect( + screen.getByText((content) => content.includes('↑') && content.includes('+5,50')), + ).toBeInTheDocument(); + }); + + it('shows down indicator for negative value', () => { + renderWithTheme(); + expect( + screen.getByText((content) => content.includes('↓') && content.includes('3,20')), + ).toBeInTheDocument(); + }); + + it('shows flat indicator for zero value', () => { + renderWithTheme(); + expect( + screen.getByText((content) => content.includes('→') && content.includes('0,00')), + ).toBeInTheDocument(); + }); + + it('formats as money', () => { + renderWithTheme(); + expect( + screen.getByText((content) => content.includes('↑') && content.includes('150,00')), + ).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/PriceChange/PriceChange.tsx b/packages/design-system/src/components/PriceChange/PriceChange.tsx new file mode 100644 index 0000000..c127970 --- /dev/null +++ b/packages/design-system/src/components/PriceChange/PriceChange.tsx @@ -0,0 +1,50 @@ +import { Text } from '../Text'; +import { Money } from '../Money'; + +export interface PriceChangeProps { + value: number; + format?: 'percent' | 'money'; + currency?: string; + locale?: string; +} + +function formatPercent(value: number, locale: string): string { + return new Intl.NumberFormat(locale, { + style: 'decimal', + minimumFractionDigits: 2, + maximumFractionDigits: 2, + signDisplay: 'always', + }).format(value); +} + +function getDirection(value: number): string { + if (value > 0) return '\u2191'; + if (value < 0) return '\u2193'; + return '\u2192'; +} + +function getTone(value: number) { + if (value > 0) return 'positive' as const; + if (value < 0) return 'negative' as const; + return 'secondary' as const; +} + +export function PriceChange({ + value, + format = 'percent', + currency = 'RUB', + locale = 'ru-RU', +}: PriceChangeProps) { + const formattedValue = + format === 'percent' ? ( + `${formatPercent(value, locale)} %` + ) : ( + + ); + + return ( + + {getDirection(value)} {formattedValue} + + ); +} diff --git a/packages/design-system/src/components/PriceChange/index.ts b/packages/design-system/src/components/PriceChange/index.ts new file mode 100644 index 0000000..179e3e3 --- /dev/null +++ b/packages/design-system/src/components/PriceChange/index.ts @@ -0,0 +1,2 @@ +export { PriceChange } from './PriceChange'; +export type { PriceChangeProps } from './PriceChange'; diff --git a/packages/design-system/src/components/Progress/Progress.stories.tsx b/packages/design-system/src/components/Progress/Progress.stories.tsx new file mode 100644 index 0000000..9214147 --- /dev/null +++ b/packages/design-system/src/components/Progress/Progress.stories.tsx @@ -0,0 +1,34 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Progress } from './Progress'; + +const meta: Meta = { + title: 'Feedback/Progress', + component: Progress, + argTypes: { + label: { control: 'text' }, + value: { control: 'number', min: 0, max: 100 }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Indeterminate: Story = { + args: { + label: 'Загрузка данных', + }, +}; + +export const Determinate: Story = { + args: { + label: 'Загрузка данных', + value: 60, + }, +}; + +export const AlmostComplete: Story = { + args: { + label: 'Экспорт портфеля', + value: 95, + }, +}; diff --git a/packages/design-system/src/components/Progress/Progress.test.tsx b/packages/design-system/src/components/Progress/Progress.test.tsx new file mode 100644 index 0000000..ae799c7 --- /dev/null +++ b/packages/design-system/src/components/Progress/Progress.test.tsx @@ -0,0 +1,40 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import { Progress } from './Progress'; +import { MoexVibeThemeProvider } from '../../theme'; + +function renderWithTheme(element: React.ReactElement) { + return render({element}); +} + +describe('Progress', () => { + it('has accessible name via label', () => { + renderWithTheme(); + const bar = screen.getByRole('progressbar'); + expect(bar).toHaveAccessibleName('Loading'); + }); + + it('renders indeterminate by default (no value)', () => { + renderWithTheme(); + const bar = screen.getByRole('progressbar'); + expect(bar).not.toHaveAttribute('aria-valuenow'); + }); + + it('renders determinate with value', () => { + renderWithTheme(); + const bar = screen.getByRole('progressbar'); + expect(bar).toHaveAttribute('aria-valuenow', '60'); + }); + + it('has aria-valuemin 0', () => { + renderWithTheme(); + const bar = screen.getByRole('progressbar'); + expect(bar).toHaveAttribute('aria-valuemin', '0'); + }); + + it('has aria-valuemax 100', () => { + renderWithTheme(); + const bar = screen.getByRole('progressbar'); + expect(bar).toHaveAttribute('aria-valuemax', '100'); + }); +}); diff --git a/packages/design-system/src/components/Progress/Progress.tsx b/packages/design-system/src/components/Progress/Progress.tsx new file mode 100644 index 0000000..88fa11d --- /dev/null +++ b/packages/design-system/src/components/Progress/Progress.tsx @@ -0,0 +1,25 @@ +import { LinearProgress, type LinearProgressProps } from '@mui/material'; + +export interface ProgressProps { + label: string; + value?: number; +} + +export function Progress({ label, value }: ProgressProps) { + const props: LinearProgressProps & { 'aria-label'?: string } = { + 'aria-label': label, + role: 'progressbar', + }; + + if (value !== undefined) { + props.value = value; + props.variant = 'determinate'; + props['aria-valuenow'] = value; + props['aria-valuemin'] = 0; + props['aria-valuemax'] = 100; + } else { + props.variant = 'indeterminate'; + } + + return ; +} diff --git a/packages/design-system/src/components/Progress/index.ts b/packages/design-system/src/components/Progress/index.ts new file mode 100644 index 0000000..387cdc3 --- /dev/null +++ b/packages/design-system/src/components/Progress/index.ts @@ -0,0 +1,2 @@ +export { Progress } from './Progress'; +export type { ProgressProps } from './Progress'; diff --git a/packages/design-system/src/components/Select/Select.stories.tsx b/packages/design-system/src/components/Select/Select.stories.tsx new file mode 100644 index 0000000..534bcf8 --- /dev/null +++ b/packages/design-system/src/components/Select/Select.stories.tsx @@ -0,0 +1,64 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { Select } from './Select'; + +const meta: Meta = { + title: 'Inputs/Select', + component: Select, + argTypes: { + label: { control: 'text' }, + error: { control: 'boolean' }, + disabled: { control: 'boolean' }, + helperText: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +const currencies = [ + { value: 'usd', label: 'Доллар США' }, + { value: 'eur', label: 'Евро' }, + { value: 'rub', label: 'Российский рубль' }, + { value: 'cny', label: 'Китайский юань' }, + { value: 'try', label: 'Турецкая лира' }, +]; + +export const Default: Story = { + args: { + label: 'Валюта', + value: '', + onChange: fn(), + options: currencies, + }, +}; + +export const WithValue: Story = { + args: { + label: 'Валюта', + value: 'usd', + onChange: fn(), + options: currencies, + }, +}; + +export const Error: Story = { + args: { + label: 'Валюта', + value: '', + onChange: fn(), + options: currencies, + error: true, + helperText: 'Выберите валюту', + }, +}; + +export const Disabled: Story = { + args: { + label: 'Валюта', + value: '', + onChange: fn(), + options: currencies, + disabled: true, + }, +}; diff --git a/packages/design-system/src/components/Select/Select.test.tsx b/packages/design-system/src/components/Select/Select.test.tsx new file mode 100644 index 0000000..d08f7c8 --- /dev/null +++ b/packages/design-system/src/components/Select/Select.test.tsx @@ -0,0 +1,61 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { Select } from './Select'; +import { MoexVibeThemeProvider } from '../../theme'; + +function renderWithTheme(element: React.ReactElement) { + return render({element}); +} + +const options = [ + { value: 'usd', label: 'USD' }, + { value: 'eur', label: 'EUR' }, + { value: 'rub', label: 'RUB', disabled: true }, +]; + +describe('Select', () => { + it('renders label', () => { + renderWithTheme( {}} options={options} />); + const button = screen.getByLabelText('Currency'); + expect(button).toBeInTheDocument(); + }); + + it('calls onChange when option is selected', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + renderWithTheme( {}} + options={options} + error + helperText="Required" + />, + ); + expect(screen.getByText('Required')).toBeInTheDocument(); + }); + + it('can be disabled', () => { + renderWithTheme( +