diff --git a/apps/frontend/.eslintrc.cjs b/apps/frontend/.eslintrc.cjs index 6d96ee8..22f9c0a 100644 --- a/apps/frontend/.eslintrc.cjs +++ b/apps/frontend/.eslintrc.cjs @@ -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', diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 9d09f79..cef656f 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -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", diff --git a/apps/frontend/src/app/providers/AppProviders.tsx b/apps/frontend/src/app/providers/AppProviders.tsx index 63f073b..2c24c08 100644 --- a/apps/frontend/src/app/providers/AppProviders.tsx +++ b/apps/frontend/src/app/providers/AppProviders.tsx @@ -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 ( - - + {children} - + ); } diff --git a/apps/frontend/src/app/styles/theme.ts b/apps/frontend/src/app/styles/theme.ts deleted file mode 100644 index 50e3776..0000000 --- a/apps/frontend/src/app/styles/theme.ts +++ /dev/null @@ -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( - ',', - ), - }, -}); diff --git a/package-lock.json b/package-lock.json index 312adbd..dc30eba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -88,8 +88,9 @@ "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.1", - "@fontsource/roboto": "^5.2.10", + "@fontsource/inter": "^5.2.8", "@hookform/resolvers": "^3.10.0", + "@moex-vibe/design-system": "*", "@mui/icons-material": "^6.5.0", "@mui/material": "^6.5.0", "@tanstack/react-query": "^5.20.0", @@ -6650,10 +6651,10 @@ } } }, - "node_modules/@fontsource/roboto": { - "version": "5.2.10", - "resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz", - "integrity": "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==", + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", "license": "OFL-1.1", "funding": { "url": "https://github.com/sponsors/ayuhito" diff --git a/packages/design-system/.eslintrc.cjs b/packages/design-system/.eslintrc.cjs new file mode 100644 index 0000000..81b8a39 --- /dev/null +++ b/packages/design-system/.eslintrc.cjs @@ -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', + }, +}; diff --git a/packages/design-system/.gitignore b/packages/design-system/.gitignore new file mode 100644 index 0000000..1591a0d --- /dev/null +++ b/packages/design-system/.gitignore @@ -0,0 +1 @@ +storybook-static/ diff --git a/packages/design-system/package.json b/packages/design-system/package.json new file mode 100644 index 0000000..ad7d09a --- /dev/null +++ b/packages/design-system/package.json @@ -0,0 +1,50 @@ +{ + "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", + "@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" + } +} diff --git a/packages/design-system/src/components/Alert/Alert.stories.tsx b/packages/design-system/src/components/Alert/Alert.stories.tsx new file mode 100644 index 0000000..aa537e7 --- /dev/null +++ b/packages/design-system/src/components/Alert/Alert.stories.tsx @@ -0,0 +1,69 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Button } from '../Button/Button'; +import { Alert } from './Alert'; + +const meta: Meta = { + title: 'Feedback/Alert', + component: Alert, + argTypes: { + severity: { + control: 'select', + options: ['info', 'success', 'warning', 'error'], + }, + title: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +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: ( + + ), + }, +}; + +export const WithoutTitle: Story = { + args: { + severity: 'info', + children: 'Просто информационное сообщение без заголовка', + }, +}; diff --git a/packages/design-system/src/components/Alert/Alert.test.tsx b/packages/design-system/src/components/Alert/Alert.test.tsx new file mode 100644 index 0000000..3d63b08 --- /dev/null +++ b/packages/design-system/src/components/Alert/Alert.test.tsx @@ -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({element}); +} + +describe('Alert', () => { + it('has role alert', () => { + renderWithTheme(Message); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('renders severity mapping for info', () => { + renderWithTheme(Info message); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('renders severity mapping for success', () => { + renderWithTheme(Success message); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('renders severity mapping for warning', () => { + renderWithTheme(Warning message); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('renders severity mapping for error', () => { + renderWithTheme(Error message); + expect(screen.getByRole('alert')).toBeInTheDocument(); + }); + + it('renders title', () => { + renderWithTheme( + + Something went wrong + , + ); + expect(screen.getByText('Error')).toBeInTheDocument(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('renders action', () => { + renderWithTheme( + Retry}> + Failed to load + , + ); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/Alert/Alert.tsx b/packages/design-system/src/components/Alert/Alert.tsx new file mode 100644 index 0000000..73a63cc --- /dev/null +++ b/packages/design-system/src/components/Alert/Alert.tsx @@ -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 { + severity: Severity; + title?: string; + children?: ReactNode; + action?: ReactNode; +} + +export function Alert({ severity, title, children, action, ...props }: AlertProps) { + return ( + + {title &&
{title}
} + {children} +
+ ); +} diff --git a/packages/design-system/src/components/Alert/index.ts b/packages/design-system/src/components/Alert/index.ts new file mode 100644 index 0000000..7a4601f --- /dev/null +++ b/packages/design-system/src/components/Alert/index.ts @@ -0,0 +1,2 @@ +export { Alert } from './Alert'; +export type { AlertProps } from './Alert'; diff --git a/packages/design-system/src/components/Badge/Badge.stories.tsx b/packages/design-system/src/components/Badge/Badge.stories.tsx new file mode 100644 index 0000000..dd5ad31 --- /dev/null +++ b/packages/design-system/src/components/Badge/Badge.stories.tsx @@ -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 = { + title: 'Surfaces/Badge', + component: Badge, + argTypes: { + value: { control: 'number' }, + max: { control: 'number' }, + label: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + value: 5, + label: 'Уведомления', + children: , + }, +}; + +export const Overflow: Story = { + args: { + value: 150, + max: 99, + label: 'Уведомления', + children: , + }, +}; + +export const Zero: Story = { + args: { + value: 0, + label: 'Уведомления', + children: , + }, +}; diff --git a/packages/design-system/src/components/Badge/Badge.test.tsx b/packages/design-system/src/components/Badge/Badge.test.tsx new file mode 100644 index 0000000..4c95e11 --- /dev/null +++ b/packages/design-system/src/components/Badge/Badge.test.tsx @@ -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({element}); +} + +describe('Badge', () => { + it('displays value', () => { + renderWithTheme(); + expect(screen.getByText('5')).toBeInTheDocument(); + }); + + it('shows max overflow', () => { + renderWithTheme(); + expect(screen.getByText('99+')).toBeInTheDocument(); + }); + + it('has accessible label', () => { + renderWithTheme(); + const badge = screen.getByLabelText('Notifications'); + expect(badge).toBeInTheDocument(); + }); + + it('does not render badge without label', () => { + const { container } = renderWithTheme(); + expect(container.querySelector('.MuiBadge-badge')).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/Badge/Badge.tsx b/packages/design-system/src/components/Badge/Badge.tsx new file mode 100644 index 0000000..1cc1867 --- /dev/null +++ b/packages/design-system/src/components/Badge/Badge.tsx @@ -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 ( + + {children} + + ); +} diff --git a/packages/design-system/src/components/Badge/index.ts b/packages/design-system/src/components/Badge/index.ts new file mode 100644 index 0000000..cd3531d --- /dev/null +++ b/packages/design-system/src/components/Badge/index.ts @@ -0,0 +1,2 @@ +export { Badge } from './Badge'; +export type { BadgeProps } from './Badge'; diff --git a/packages/design-system/src/components/Card/Card.stories.tsx b/packages/design-system/src/components/Card/Card.stories.tsx new file mode 100644 index 0000000..c8ad0a9 --- /dev/null +++ b/packages/design-system/src/components/Card/Card.stories.tsx @@ -0,0 +1,56 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Button } from '../Button/Button'; +import { Card } from './Card'; + +const meta: Meta = { + 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; + +export const Default: Story = { + args: { + children: 'Основное содержимое карточки', + }, +}; + +export const WithHeader: Story = { + args: { + header: Заголовок карточки, + children: 'Текст внутри карточки с заголовком', + }, +}; + +export const WithActions: Story = { + args: { + children: 'Карточка с действиями', + actions: , + }, +}; + +export const FullCard: Story = { + args: { + header: Портфель акций, + children: 'Состав портфеля: Сбербанк, Лукойл, Газпром', + actions: ( +
+ + +
+ ), + }, +}; diff --git a/packages/design-system/src/components/Card/Card.test.tsx b/packages/design-system/src/components/Card/Card.test.tsx new file mode 100644 index 0000000..d6866d3 --- /dev/null +++ b/packages/design-system/src/components/Card/Card.test.tsx @@ -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({element}); +} + +describe('Card', () => { + it('renders children', () => { + renderWithTheme(Body); + expect(screen.getByText('Body')).toBeInTheDocument(); + }); + + it('renders header', () => { + renderWithTheme(Body); + expect(screen.getByText('Header')).toBeInTheDocument(); + }); + + it('renders actions', () => { + renderWithTheme(Action}>Body); + expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument(); + }); + + it('accepts Surface padding prop', () => { + renderWithTheme( + + Body + , + ); + expect(screen.getByText('Body').closest('.MuiPaper-root')).toBeInTheDocument(); + }); + + it('renders without header and actions', () => { + renderWithTheme(Body); + expect(screen.getByText('Body')).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/Card/Card.tsx b/packages/design-system/src/components/Card/Card.tsx new file mode 100644 index 0000000..17a0fa6 --- /dev/null +++ b/packages/design-system/src/components/Card/Card.tsx @@ -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 ( + + {header &&
{header}
} + {children} + {actions &&
{actions}
} +
+ ); +} diff --git a/packages/design-system/src/components/Card/index.ts b/packages/design-system/src/components/Card/index.ts new file mode 100644 index 0000000..050a95d --- /dev/null +++ b/packages/design-system/src/components/Card/index.ts @@ -0,0 +1,2 @@ +export { Card } from './Card'; +export type { CardProps } from './Card'; diff --git a/packages/design-system/src/components/Checkbox/Checkbox.stories.tsx b/packages/design-system/src/components/Checkbox/Checkbox.stories.tsx new file mode 100644 index 0000000..040cd4c --- /dev/null +++ b/packages/design-system/src/components/Checkbox/Checkbox.stories.tsx @@ -0,0 +1,51 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { Checkbox } from './Checkbox'; + +const meta: Meta = { + title: 'Inputs/Checkbox', + component: Checkbox, + argTypes: { + label: { control: 'text' }, + checked: { control: 'boolean' }, + disabled: { control: 'boolean' }, + error: { control: 'boolean' }, + }, +}; + +export default meta; +type Story = StoryObj; + +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, + }, +}; diff --git a/packages/design-system/src/components/Checkbox/Checkbox.test.tsx b/packages/design-system/src/components/Checkbox/Checkbox.test.tsx new file mode 100644 index 0000000..49af3ba --- /dev/null +++ b/packages/design-system/src/components/Checkbox/Checkbox.test.tsx @@ -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({element}); +} + +describe('Checkbox', () => { + it('renders label', () => { + renderWithTheme( {}} />); + expect(screen.getByText('Agree')).toBeInTheDocument(); + }); + + it('toggles on label click', async () => { + const handleChange = vi.fn(); + const user = userEvent.setup(); + renderWithTheme(); + 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(); + 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(); + await user.click(screen.getByRole('checkbox')); + expect(handleChange).toHaveBeenCalledWith(false); + }); + + it('can be disabled', () => { + renderWithTheme( {}} disabled />); + expect(screen.getByRole('checkbox')).toBeDisabled(); + }); + + it('shows error state', () => { + renderWithTheme( {}} error />); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).toHaveAttribute('aria-invalid', 'true'); + }); + + it('does not pass sx to DOM', () => { + renderWithTheme( {}} />); + const checkbox = screen.getByRole('checkbox'); + expect(checkbox).not.toHaveAttribute('sx'); + }); +}); diff --git a/packages/design-system/src/components/Checkbox/Checkbox.tsx b/packages/design-system/src/components/Checkbox/Checkbox.tsx new file mode 100644 index 0000000..f414b5f --- /dev/null +++ b/packages/design-system/src/components/Checkbox/Checkbox.tsx @@ -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 ( + onChange(ch)} + disabled={disabled} + inputProps={error ? { 'aria-invalid': 'true' as const } : undefined} + /> + } + label={label} + disabled={disabled} + /> + ); +} diff --git a/packages/design-system/src/components/Checkbox/index.ts b/packages/design-system/src/components/Checkbox/index.ts new file mode 100644 index 0000000..43aaf92 --- /dev/null +++ b/packages/design-system/src/components/Checkbox/index.ts @@ -0,0 +1,2 @@ +export { Checkbox } from './Checkbox'; +export type { CheckboxProps } from './Checkbox'; diff --git a/packages/design-system/src/components/Chip/Chip.stories.tsx b/packages/design-system/src/components/Chip/Chip.stories.tsx new file mode 100644 index 0000000..072a9ab --- /dev/null +++ b/packages/design-system/src/components/Chip/Chip.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { fn } from '@storybook/test'; +import { Chip } from './Chip'; + +const meta: Meta = { + 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; + +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: () => ( +
+ + + + + +
+ ), +}; diff --git a/packages/design-system/src/components/Chip/Chip.test.tsx b/packages/design-system/src/components/Chip/Chip.test.tsx new file mode 100644 index 0000000..d5d91ea --- /dev/null +++ b/packages/design-system/src/components/Chip/Chip.test.tsx @@ -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({element}); +} + +describe('Chip', () => { + it('renders label', () => { + renderWithTheme(); + expect(screen.getByText('Default')).toBeInTheDocument(); + }); + + it('renders with neutral tone by default', () => { + renderWithTheme(); + const chip = screen.getByText('Neutral').closest('.MuiChip-root')!; + expect(chip.classList.contains('MuiChip-filled')).toBe(true); + }); + + it('renders info tone', () => { + renderWithTheme(); + expect(screen.getByText('Info')).toBeInTheDocument(); + }); + + it('renders success tone', () => { + renderWithTheme(); + expect(screen.getByText('Success')).toBeInTheDocument(); + }); + + it('renders warning tone', () => { + renderWithTheme(); + expect(screen.getByText('Warning')).toBeInTheDocument(); + }); + + it('renders error tone', () => { + renderWithTheme(); + expect(screen.getByText('Error')).toBeInTheDocument(); + }); + + it('shows delete button when onDelete provided', () => { + renderWithTheme( {}} />); + 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(); + const deleteIcon = screen.getByLabelText('Remove Removable'); + await user.click(deleteIcon); + expect(handleDelete).toHaveBeenCalledTimes(1); + }); + + it('does not show delete button without onDelete', () => { + renderWithTheme(); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/Chip/Chip.tsx b/packages/design-system/src/components/Chip/Chip.tsx new file mode 100644 index 0000000..f6b328e --- /dev/null +++ b/packages/design-system/src/components/Chip/Chip.tsx @@ -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 = { + 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 ( + } : {})} + /> + ); +} diff --git a/packages/design-system/src/components/Chip/index.ts b/packages/design-system/src/components/Chip/index.ts new file mode 100644 index 0000000..cbdcf05 --- /dev/null +++ b/packages/design-system/src/components/Chip/index.ts @@ -0,0 +1,2 @@ +export { Chip } from './Chip'; +export type { ChipProps } from './Chip'; diff --git a/packages/design-system/src/components/DataTable/DataTable.stories.tsx b/packages/design-system/src/components/DataTable/DataTable.stories.tsx new file mode 100644 index 0000000..df90297 --- /dev/null +++ b/packages/design-system/src/components/DataTable/DataTable.stories.tsx @@ -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(); + +const columns = [ + columnHelper.accessor('ticker', { header: 'Тикер' }), + columnHelper.accessor('price', { header: 'Цена' }), + columnHelper.accessor('change', { header: 'Изменение' }), +]; + +function SortableTableStory() { + const [sorting, setSorting] = useState([]); + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + state: { sorting }, + onSortingChange: setSorting, + }); + return ( + + + + ); +} + +const meta: Meta = { + title: 'Data/DataTable', + component: DataTable, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + render: () => { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ( + + + + ); + }, +}; + +export const Compact: Story = { + render: () => { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ( + + + + ); + }, +}; + +export const Sortable: Story = { + render: () => , +}; + +export const Empty: Story = { + render: () => { + const table = useReactTable({ + data: [], + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ( + + Нет данных} /> + + ); + }, +}; + +export const Loading: Story = { + render: () => { + const table = useReactTable({ + data: [], + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ( + + + + ); + }, +}; diff --git a/packages/design-system/src/components/DataTable/DataTable.test.tsx b/packages/design-system/src/components/DataTable/DataTable.test.tsx new file mode 100644 index 0000000..0787eca --- /dev/null +++ b/packages/design-system/src/components/DataTable/DataTable.test.tsx @@ -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(); + +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 ( + + + + ); +} + +function TestTableWithProps(props: { + caption: string; + loading?: boolean; + empty?: React.ReactNode; + density?: 'balanced' | 'compact'; +}) { + const table = useReactTable({ + data, + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ( + + + + ); +} + +function EmptyTable() { + const table = useReactTable({ + data: [], + columns, + getCoreRowModel: getCoreRowModel(), + }); + return ( + + No data} /> + + ); +} + +describe('DataTable', () => { + it('renders caption', () => { + render(); + expect(screen.getByText('Test Caption')).toBeInTheDocument(); + }); + + it('renders column headers as th elements', () => { + render(); + const headers = screen.getAllByRole('columnheader'); + expect(headers).toHaveLength(2); + expect(headers[0]).toHaveTextContent('Name'); + expect(headers[1]).toHaveTextContent('Price'); + }); + + it('renders data rows', () => { + render(); + expect(screen.getByText('AAPL')).toBeInTheDocument(); + expect(screen.getByText('GOOGL')).toBeInTheDocument(); + }); + + it('renders with compact density', () => { + render(); + expect(screen.getByRole('table')).toBeInTheDocument(); + }); + + it('shows empty state when no data', () => { + render(); + expect(screen.getByText('No data')).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/DataTable/DataTable.tsx b/packages/design-system/src/components/DataTable/DataTable.tsx new file mode 100644 index 0000000..911e109 --- /dev/null +++ b/packages/design-system/src/components/DataTable/DataTable.tsx @@ -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 { + table: TanStackTable; + density?: Density; + loading?: boolean; + empty?: ReactNode; + caption: string; +} + +const DENSITY_PADDING: Record = { + balanced: 'medium', + compact: 'small', +}; + +export function DataTable({ + table, + density = 'balanced', + loading, + empty, + caption, +}: DataTableProps) { + const rows = table.getRowModel().rows; + + if (!loading && rows.length === 0 && empty) { + return <>{empty}; + } + + return ( + + + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
{caption}
+
+ ); +} diff --git a/packages/design-system/src/components/DataTable/index.ts b/packages/design-system/src/components/DataTable/index.ts new file mode 100644 index 0000000..39d6048 --- /dev/null +++ b/packages/design-system/src/components/DataTable/index.ts @@ -0,0 +1,2 @@ +export { DataTable } from './DataTable'; +export type { DataTableProps, Density } from './DataTable'; diff --git a/packages/design-system/src/components/Dialog/Dialog.stories.tsx b/packages/design-system/src/components/Dialog/Dialog.stories.tsx new file mode 100644 index 0000000..1bec21b --- /dev/null +++ b/packages/design-system/src/components/Dialog/Dialog.stories.tsx @@ -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 = { + title: 'Feedback/Dialog', + component: Dialog, + argTypes: { + open: { control: 'boolean' }, + title: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + open: true, + onClose: fn(), + title: 'Подтверждение', + children: 'Вы уверены, что хотите удалить этот портфель?', + }, +}; + +export const WithActions: Story = { + args: { + open: true, + onClose: fn(), + title: 'Удаление портфеля', + children: 'Портфель "Акции США" будет безвозвратно удалён', + actions: ( +
+ + +
+ ), + }, +}; + +export const Interactive: Story = { + render: () => { + // eslint-disable-next-line react-hooks/rules-of-hooks + const [open, setOpen] = useState(false); + return ( + <> + + setOpen(false)} title="Модальное окно"> +

Это интерактивный пример диалога.

+

Нажмите Escape или кликните вне окна для закрытия.

+
+ + ); + }, +}; diff --git a/packages/design-system/src/components/Dialog/Dialog.test.tsx b/packages/design-system/src/components/Dialog/Dialog.test.tsx new file mode 100644 index 0000000..067d915 --- /dev/null +++ b/packages/design-system/src/components/Dialog/Dialog.test.tsx @@ -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({element}); +} + +describe('Dialog', () => { + it('renders when open', () => { + renderWithTheme( + {}} title="Confirm"> + Are you sure? + , + ); + expect(screen.getByRole('dialog')).toBeInTheDocument(); + }); + + it('does not render when closed', () => { + renderWithTheme( + {}} title="Confirm"> + Are you sure? + , + ); + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + + it('has aria-labelledby pointing to title', () => { + renderWithTheme( + {}} title="Confirm"> + Content + , + ); + 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( + + Are you sure? + , + ); + await user.keyboard('{Escape}'); + expect(handleClose).toHaveBeenCalledTimes(1); + }); + + it('renders title', () => { + renderWithTheme( + {}} title="Confirm"> + Content + , + ); + expect(screen.getByText('Confirm')).toBeInTheDocument(); + }); + + it('renders children', () => { + renderWithTheme( + {}} title="Confirm"> + Are you sure? + , + ); + expect(screen.getByText('Are you sure?')).toBeInTheDocument(); + }); + + it('renders actions', () => { + renderWithTheme( + {}} title="Confirm" actions={}> + Content + , + ); + expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/Dialog/Dialog.tsx b/packages/design-system/src/components/Dialog/Dialog.tsx new file mode 100644 index 0000000..af88d10 --- /dev/null +++ b/packages/design-system/src/components/Dialog/Dialog.tsx @@ -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 ( + + {title} + {children} + {actions && {actions}} + + ); +} diff --git a/packages/design-system/src/components/Dialog/index.ts b/packages/design-system/src/components/Dialog/index.ts new file mode 100644 index 0000000..4fdb7d5 --- /dev/null +++ b/packages/design-system/src/components/Dialog/index.ts @@ -0,0 +1,2 @@ +export { Dialog } from './Dialog'; +export type { DialogProps } from './Dialog'; diff --git a/packages/design-system/src/components/EmptyState/EmptyState.tsx b/packages/design-system/src/components/EmptyState/EmptyState.tsx index 80eb7f2..019f708 100644 --- a/packages/design-system/src/components/EmptyState/EmptyState.tsx +++ b/packages/design-system/src/components/EmptyState/EmptyState.tsx @@ -12,7 +12,7 @@ export interface EmptyStateProps { export function EmptyState({ title, description, action }: EmptyStateProps) { return ( - {title} + {title} {description && {description}} {action &&
{action}
}
diff --git a/packages/design-system/src/components/ErrorState/ErrorState.tsx b/packages/design-system/src/components/ErrorState/ErrorState.tsx index f4fd49f..2cbc3e3 100644 --- a/packages/design-system/src/components/ErrorState/ErrorState.tsx +++ b/packages/design-system/src/components/ErrorState/ErrorState.tsx @@ -12,7 +12,7 @@ export interface ErrorStateProps { export function ErrorState({ title, description, action }: ErrorStateProps) { return ( - {title} + {title} {description && {description}} {action &&
{action}
}
diff --git a/packages/design-system/src/components/LoadingState/LoadingState.tsx b/packages/design-system/src/components/LoadingState/LoadingState.tsx index e8685bd..df550c9 100644 --- a/packages/design-system/src/components/LoadingState/LoadingState.tsx +++ b/packages/design-system/src/components/LoadingState/LoadingState.tsx @@ -22,7 +22,7 @@ export function LoadingState({ title, label, size = 'section' }: LoadingStatePro > - {title} + {title} {label && {label}} 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( +