codex/design-system-foundation #33
@ -29,6 +29,12 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'],
|
ignorePatterns: ['.eslintrc.cjs', 'vite.config.ts', 'vitest.config.ts', 'dist/'],
|
||||||
rules: {
|
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-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
'@typescript-eslint/no-explicit-any': 'off',
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
'react/react-in-jsx-scope': 'off',
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
|||||||
@ -15,7 +15,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/roboto": "^5.2.10",
|
"@fontsource/inter": "^5.2.8",
|
||||||
|
"@moex-vibe/design-system": "*",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
"@mui/icons-material": "^6.5.0",
|
"@mui/icons-material": "^6.5.0",
|
||||||
"@mui/material": "^6.5.0",
|
"@mui/material": "^6.5.0",
|
||||||
|
|||||||
@ -1,9 +1,11 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import '@fontsource/inter/400.css';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
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 { SessionProvider } from './SessionProvider';
|
||||||
import { theme } from '@/app/styles/theme';
|
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
@ -17,11 +19,10 @@ const queryClient = new QueryClient({
|
|||||||
|
|
||||||
export function AppProviders({ children }: { children: ReactNode }) {
|
export function AppProviders({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<ThemeProvider theme={theme} defaultMode="light">
|
<MoexVibeThemeProvider>
|
||||||
<CssBaseline />
|
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<SessionProvider>{children}</SessionProvider>
|
<SessionProvider>{children}</SessionProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
</ThemeProvider>
|
</MoexVibeThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
import { createTheme } from '@mui/material/styles';
|
|
||||||
|
|
||||||
export const theme = createTheme({
|
|
||||||
cssVariables: true,
|
|
||||||
colorSchemes: {
|
|
||||||
light: {
|
|
||||||
palette: {
|
|
||||||
primary: { main: '#1976d2' },
|
|
||||||
success: { main: '#2e7d32' },
|
|
||||||
error: { main: '#c62828' },
|
|
||||||
background: { default: '#f5f5f5', paper: '#ffffff' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
typography: {
|
|
||||||
fontFamily: ['-apple-system', 'BlinkMacSystemFont', "'Segoe UI'", 'Roboto', 'sans-serif'].join(
|
|
||||||
',',
|
|
||||||
),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@ -88,8 +88,9 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@emotion/react": "^11.14.0",
|
"@emotion/react": "^11.14.0",
|
||||||
"@emotion/styled": "^11.14.1",
|
"@emotion/styled": "^11.14.1",
|
||||||
"@fontsource/roboto": "^5.2.10",
|
"@fontsource/inter": "^5.2.8",
|
||||||
"@hookform/resolvers": "^3.10.0",
|
"@hookform/resolvers": "^3.10.0",
|
||||||
|
"@moex-vibe/design-system": "*",
|
||||||
"@mui/icons-material": "^6.5.0",
|
"@mui/icons-material": "^6.5.0",
|
||||||
"@mui/material": "^6.5.0",
|
"@mui/material": "^6.5.0",
|
||||||
"@tanstack/react-query": "^5.20.0",
|
"@tanstack/react-query": "^5.20.0",
|
||||||
@ -6650,10 +6651,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@fontsource/roboto": {
|
"node_modules/@fontsource/inter": {
|
||||||
"version": "5.2.10",
|
"version": "5.2.8",
|
||||||
"resolved": "https://registry.npmjs.org/@fontsource/roboto/-/roboto-5.2.10.tgz",
|
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
|
||||||
"integrity": "sha512-8HlA5FtSfz//oFSr2eL7GFXAiE7eIkcGOtx7tjsLKq+as702x9+GU7K95iDeWFapHC4M2hv9RrpXKRTGGBI8Zg==",
|
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
|
||||||
"license": "OFL-1.1",
|
"license": "OFL-1.1",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ayuhito"
|
"url": "https://github.com/sponsors/ayuhito"
|
||||||
|
|||||||
28
packages/design-system/.eslintrc.cjs
Normal file
28
packages/design-system/.eslintrc.cjs
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
module.exports = {
|
||||||
|
parser: '@typescript-eslint/parser',
|
||||||
|
parserOptions: {
|
||||||
|
sourceType: 'module',
|
||||||
|
ecmaFeatures: { jsx: true },
|
||||||
|
},
|
||||||
|
plugins: ['@typescript-eslint/eslint-plugin', 'react', 'react-hooks'],
|
||||||
|
extends: [
|
||||||
|
'plugin:@typescript-eslint/recommended',
|
||||||
|
'plugin:react/recommended',
|
||||||
|
'plugin:react-hooks/recommended',
|
||||||
|
],
|
||||||
|
root: true,
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
es2020: true,
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
react: { version: 'detect' },
|
||||||
|
},
|
||||||
|
ignorePatterns: ['.eslintrc.cjs', 'vitest.config.ts', 'dist/', '.storybook/'],
|
||||||
|
rules: {
|
||||||
|
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
|
||||||
|
'@typescript-eslint/no-explicit-any': 'off',
|
||||||
|
'react/react-in-jsx-scope': 'off',
|
||||||
|
},
|
||||||
|
};
|
||||||
1
packages/design-system/.gitignore
vendored
Normal file
1
packages/design-system/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
storybook-static/
|
||||||
50
packages/design-system/package.json
Normal file
50
packages/design-system/package.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,69 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Button } from '../Button/Button';
|
||||||
|
import { Alert } from './Alert';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Alert> = {
|
||||||
|
title: 'Feedback/Alert',
|
||||||
|
component: Alert,
|
||||||
|
argTypes: {
|
||||||
|
severity: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['info', 'success', 'warning', 'error'],
|
||||||
|
},
|
||||||
|
title: { control: 'text' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Alert>;
|
||||||
|
|
||||||
|
export const Info: Story = {
|
||||||
|
args: {
|
||||||
|
severity: 'info',
|
||||||
|
title: 'Информация',
|
||||||
|
children: 'Данные обновляются каждые 15 минут',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Success: Story = {
|
||||||
|
args: {
|
||||||
|
severity: 'success',
|
||||||
|
title: 'Успешно',
|
||||||
|
children: 'Портфель успешно создан',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Warning: Story = {
|
||||||
|
args: {
|
||||||
|
severity: 'warning',
|
||||||
|
title: 'Внимание',
|
||||||
|
children: 'Цена акции превысила лимит',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error: Story = {
|
||||||
|
args: {
|
||||||
|
severity: 'error',
|
||||||
|
title: 'Ошибка',
|
||||||
|
children: 'Не удалось загрузить данные',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithAction: Story = {
|
||||||
|
args: {
|
||||||
|
severity: 'warning',
|
||||||
|
children: 'Обновите страницу для получения актуальных данных',
|
||||||
|
action: (
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithoutTitle: Story = {
|
||||||
|
args: {
|
||||||
|
severity: 'info',
|
||||||
|
children: 'Просто информационное сообщение без заголовка',
|
||||||
|
},
|
||||||
|
};
|
||||||
54
packages/design-system/src/components/Alert/Alert.test.tsx
Normal file
54
packages/design-system/src/components/Alert/Alert.test.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Alert } from './Alert';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Alert', () => {
|
||||||
|
it('has role alert', () => {
|
||||||
|
renderWithTheme(<Alert severity="info">Message</Alert>);
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders severity mapping for info', () => {
|
||||||
|
renderWithTheme(<Alert severity="info">Info message</Alert>);
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders severity mapping for success', () => {
|
||||||
|
renderWithTheme(<Alert severity="success">Success message</Alert>);
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders severity mapping for warning', () => {
|
||||||
|
renderWithTheme(<Alert severity="warning">Warning message</Alert>);
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders severity mapping for error', () => {
|
||||||
|
renderWithTheme(<Alert severity="error">Error message</Alert>);
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders title', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Alert severity="error" title="Error">
|
||||||
|
Something went wrong
|
||||||
|
</Alert>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders action', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Alert severity="warning" action={<button>Retry</button>}>
|
||||||
|
Failed to load
|
||||||
|
</Alert>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
20
packages/design-system/src/components/Alert/Alert.tsx
Normal file
20
packages/design-system/src/components/Alert/Alert.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import { Alert as MuiAlert, type AlertProps as MuiAlertProps } from '@mui/material';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Severity = 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
export interface AlertProps extends Omit<MuiAlertProps, 'severity' | 'variant' | 'color' | 'sx'> {
|
||||||
|
severity: Severity;
|
||||||
|
title?: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
action?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Alert({ severity, title, children, action, ...props }: AlertProps) {
|
||||||
|
return (
|
||||||
|
<MuiAlert severity={severity} variant="filled" action={action} role="alert" {...props}>
|
||||||
|
{title && <div>{title}</div>}
|
||||||
|
{children}
|
||||||
|
</MuiAlert>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Alert/index.ts
Normal file
2
packages/design-system/src/components/Alert/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Alert } from './Alert';
|
||||||
|
export type { AlertProps } from './Alert';
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import MailIcon from '@mui/icons-material/Mail';
|
||||||
|
import { Badge } from './Badge';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Badge> = {
|
||||||
|
title: 'Surfaces/Badge',
|
||||||
|
component: Badge,
|
||||||
|
argTypes: {
|
||||||
|
value: { control: 'number' },
|
||||||
|
max: { control: 'number' },
|
||||||
|
label: { control: 'text' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Badge>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
value: 5,
|
||||||
|
label: 'Уведомления',
|
||||||
|
children: <MailIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Overflow: Story = {
|
||||||
|
args: {
|
||||||
|
value: 150,
|
||||||
|
max: 99,
|
||||||
|
label: 'Уведомления',
|
||||||
|
children: <MailIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Zero: Story = {
|
||||||
|
args: {
|
||||||
|
value: 0,
|
||||||
|
label: 'Уведомления',
|
||||||
|
children: <MailIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
31
packages/design-system/src/components/Badge/Badge.test.tsx
Normal file
31
packages/design-system/src/components/Badge/Badge.test.tsx
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Badge } from './Badge';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Badge', () => {
|
||||||
|
it('displays value', () => {
|
||||||
|
renderWithTheme(<Badge value={5} label="Notifications" />);
|
||||||
|
expect(screen.getByText('5')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows max overflow', () => {
|
||||||
|
renderWithTheme(<Badge value={150} max={99} label="Notifications" />);
|
||||||
|
expect(screen.getByText('99+')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has accessible label', () => {
|
||||||
|
renderWithTheme(<Badge value={3} label="Notifications" />);
|
||||||
|
const badge = screen.getByLabelText('Notifications');
|
||||||
|
expect(badge).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render badge without label', () => {
|
||||||
|
const { container } = renderWithTheme(<Badge value={3} label="" />);
|
||||||
|
expect(container.querySelector('.MuiBadge-badge')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
packages/design-system/src/components/Badge/Badge.tsx
Normal file
17
packages/design-system/src/components/Badge/Badge.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { Badge as MuiBadge } from '@mui/material';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface BadgeProps {
|
||||||
|
value: number;
|
||||||
|
max?: number;
|
||||||
|
label: string;
|
||||||
|
children?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ value, max = 99, label, children }: BadgeProps) {
|
||||||
|
return (
|
||||||
|
<MuiBadge badgeContent={value} max={max} aria-label={label}>
|
||||||
|
{children}
|
||||||
|
</MuiBadge>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Badge/index.ts
Normal file
2
packages/design-system/src/components/Badge/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Badge } from './Badge';
|
||||||
|
export type { BadgeProps } from './Badge';
|
||||||
56
packages/design-system/src/components/Card/Card.stories.tsx
Normal file
56
packages/design-system/src/components/Card/Card.stories.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Button } from '../Button/Button';
|
||||||
|
import { Card } from './Card';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Card> = {
|
||||||
|
title: 'Surfaces/Card',
|
||||||
|
component: Card,
|
||||||
|
argTypes: {
|
||||||
|
padding: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['none', 'sm', 'md', 'lg'],
|
||||||
|
},
|
||||||
|
elevation: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['none', 'sm', 'md'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Card>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Основное содержимое карточки',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithHeader: Story = {
|
||||||
|
args: {
|
||||||
|
header: <strong>Заголовок карточки</strong>,
|
||||||
|
children: 'Текст внутри карточки с заголовком',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithActions: Story = {
|
||||||
|
args: {
|
||||||
|
children: 'Карточка с действиями',
|
||||||
|
actions: <Button size="small">Подробнее</Button>,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FullCard: Story = {
|
||||||
|
args: {
|
||||||
|
header: <strong>Портфель акций</strong>,
|
||||||
|
children: 'Состав портфеля: Сбербанк, Лукойл, Газпром',
|
||||||
|
actions: (
|
||||||
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||||
|
<Button size="small" variant="secondary">
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button size="small">Сохранить</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
39
packages/design-system/src/components/Card/Card.test.tsx
Normal file
39
packages/design-system/src/components/Card/Card.test.tsx
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Card } from './Card';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Card', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
renderWithTheme(<Card>Body</Card>);
|
||||||
|
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders header', () => {
|
||||||
|
renderWithTheme(<Card header="Header">Body</Card>);
|
||||||
|
expect(screen.getByText('Header')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders actions', () => {
|
||||||
|
renderWithTheme(<Card actions={<button>Action</button>}>Body</Card>);
|
||||||
|
expect(screen.getByRole('button', { name: 'Action' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts Surface padding prop', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Card padding="none" header="Header">
|
||||||
|
Body
|
||||||
|
</Card>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Body').closest('.MuiPaper-root')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders without header and actions', () => {
|
||||||
|
renderWithTheme(<Card>Body</Card>);
|
||||||
|
expect(screen.getByText('Body')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
17
packages/design-system/src/components/Card/Card.tsx
Normal file
17
packages/design-system/src/components/Card/Card.tsx
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Surface, type SurfaceProps } from '../Surface/Surface';
|
||||||
|
|
||||||
|
export interface CardProps extends SurfaceProps {
|
||||||
|
header?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Card({ header, actions, children, ...surfaceProps }: CardProps) {
|
||||||
|
return (
|
||||||
|
<Surface {...surfaceProps}>
|
||||||
|
{header && <div>{header}</div>}
|
||||||
|
{children}
|
||||||
|
{actions && <div>{actions}</div>}
|
||||||
|
</Surface>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Card/index.ts
Normal file
2
packages/design-system/src/components/Card/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Card } from './Card';
|
||||||
|
export type { CardProps } from './Card';
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { fn } from '@storybook/test';
|
||||||
|
import { Checkbox } from './Checkbox';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Checkbox> = {
|
||||||
|
title: 'Inputs/Checkbox',
|
||||||
|
component: Checkbox,
|
||||||
|
argTypes: {
|
||||||
|
label: { control: 'text' },
|
||||||
|
checked: { control: 'boolean' },
|
||||||
|
disabled: { control: 'boolean' },
|
||||||
|
error: { control: 'boolean' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Checkbox>;
|
||||||
|
|
||||||
|
export const Unchecked: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Согласен с условиями',
|
||||||
|
checked: false,
|
||||||
|
onChange: fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Checked: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Согласен с условиями',
|
||||||
|
checked: true,
|
||||||
|
onChange: fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Недоступно',
|
||||||
|
checked: false,
|
||||||
|
onChange: fn(),
|
||||||
|
disabled: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Согласен с условиями',
|
||||||
|
checked: false,
|
||||||
|
onChange: fn(),
|
||||||
|
error: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Checkbox } from './Checkbox';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Checkbox', () => {
|
||||||
|
it('renders label', () => {
|
||||||
|
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={() => {}} />);
|
||||||
|
expect(screen.getByText('Agree')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles on label click', async () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={handleChange} />);
|
||||||
|
await user.click(screen.getByText('Agree'));
|
||||||
|
expect(handleChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('toggles on checkbox click', async () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={handleChange} />);
|
||||||
|
await user.click(screen.getByRole('checkbox'));
|
||||||
|
expect(handleChange).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onChange with false when checked', async () => {
|
||||||
|
const handleChange = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithTheme(<Checkbox label="Agree" checked={true} onChange={handleChange} />);
|
||||||
|
await user.click(screen.getByRole('checkbox'));
|
||||||
|
expect(handleChange).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be disabled', () => {
|
||||||
|
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={() => {}} disabled />);
|
||||||
|
expect(screen.getByRole('checkbox')).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state', () => {
|
||||||
|
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={() => {}} error />);
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
expect(checkbox).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pass sx to DOM', () => {
|
||||||
|
renderWithTheme(<Checkbox label="Agree" checked={false} onChange={() => {}} />);
|
||||||
|
const checkbox = screen.getByRole('checkbox');
|
||||||
|
expect(checkbox).not.toHaveAttribute('sx');
|
||||||
|
});
|
||||||
|
});
|
||||||
26
packages/design-system/src/components/Checkbox/Checkbox.tsx
Normal file
26
packages/design-system/src/components/Checkbox/Checkbox.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { FormControlLabel, Checkbox as MuiCheckbox } from '@mui/material';
|
||||||
|
|
||||||
|
export interface CheckboxProps {
|
||||||
|
label: string;
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
error?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Checkbox({ label, checked, onChange, disabled, error }: CheckboxProps) {
|
||||||
|
return (
|
||||||
|
<FormControlLabel
|
||||||
|
control={
|
||||||
|
<MuiCheckbox
|
||||||
|
checked={checked}
|
||||||
|
onChange={(_, ch) => onChange(ch)}
|
||||||
|
disabled={disabled}
|
||||||
|
inputProps={error ? { 'aria-invalid': 'true' as const } : undefined}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Checkbox/index.ts
Normal file
2
packages/design-system/src/components/Checkbox/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Checkbox } from './Checkbox';
|
||||||
|
export type { CheckboxProps } from './Checkbox';
|
||||||
74
packages/design-system/src/components/Chip/Chip.stories.tsx
Normal file
74
packages/design-system/src/components/Chip/Chip.stories.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { fn } from '@storybook/test';
|
||||||
|
import { Chip } from './Chip';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Chip> = {
|
||||||
|
title: 'Surfaces/Chip',
|
||||||
|
component: Chip,
|
||||||
|
argTypes: {
|
||||||
|
label: { control: 'text' },
|
||||||
|
tone: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['neutral', 'info', 'success', 'warning', 'error'],
|
||||||
|
},
|
||||||
|
onDelete: { action: 'deleted' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Chip>;
|
||||||
|
|
||||||
|
export const Neutral: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Черновик',
|
||||||
|
tone: 'neutral',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Info: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Новое',
|
||||||
|
tone: 'info',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Success: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Активно',
|
||||||
|
tone: 'success',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Warning: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Внимание',
|
||||||
|
tone: 'warning',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Ошибка',
|
||||||
|
tone: 'error',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Deletable: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Акция',
|
||||||
|
tone: 'info',
|
||||||
|
onDelete: fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllTones: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||||
|
<Chip label="Черновик" tone="neutral" />
|
||||||
|
<Chip label="Новое" tone="info" />
|
||||||
|
<Chip label="Активно" tone="success" />
|
||||||
|
<Chip label="Внимание" tone="warning" />
|
||||||
|
<Chip label="Ошибка" tone="error" />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
62
packages/design-system/src/components/Chip/Chip.test.tsx
Normal file
62
packages/design-system/src/components/Chip/Chip.test.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Chip } from './Chip';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Chip', () => {
|
||||||
|
it('renders label', () => {
|
||||||
|
renderWithTheme(<Chip label="Default" />);
|
||||||
|
expect(screen.getByText('Default')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with neutral tone by default', () => {
|
||||||
|
renderWithTheme(<Chip label="Neutral" />);
|
||||||
|
const chip = screen.getByText('Neutral').closest('.MuiChip-root')!;
|
||||||
|
expect(chip.classList.contains('MuiChip-filled')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders info tone', () => {
|
||||||
|
renderWithTheme(<Chip label="Info" tone="info" />);
|
||||||
|
expect(screen.getByText('Info')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders success tone', () => {
|
||||||
|
renderWithTheme(<Chip label="Success" tone="success" />);
|
||||||
|
expect(screen.getByText('Success')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders warning tone', () => {
|
||||||
|
renderWithTheme(<Chip label="Warning" tone="warning" />);
|
||||||
|
expect(screen.getByText('Warning')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders error tone', () => {
|
||||||
|
renderWithTheme(<Chip label="Error" tone="error" />);
|
||||||
|
expect(screen.getByText('Error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows delete button when onDelete provided', () => {
|
||||||
|
renderWithTheme(<Chip label="Removable" onDelete={() => {}} />);
|
||||||
|
const deleteIcon = screen.getByLabelText('Remove Removable');
|
||||||
|
expect(deleteIcon).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onDelete when delete clicked', async () => {
|
||||||
|
const handleDelete = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithTheme(<Chip label="Removable" onDelete={handleDelete} />);
|
||||||
|
const deleteIcon = screen.getByLabelText('Remove Removable');
|
||||||
|
await user.click(deleteIcon);
|
||||||
|
expect(handleDelete).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not show delete button without onDelete', () => {
|
||||||
|
renderWithTheme(<Chip label="Static" />);
|
||||||
|
expect(screen.queryByRole('button')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
29
packages/design-system/src/components/Chip/Chip.tsx
Normal file
29
packages/design-system/src/components/Chip/Chip.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Chip as MuiChip } from '@mui/material';
|
||||||
|
import CancelIcon from '@mui/icons-material/Cancel';
|
||||||
|
|
||||||
|
type Tone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
|
||||||
|
|
||||||
|
const TONE_MAP: Record<Tone, 'default' | 'info' | 'success' | 'warning' | 'error'> = {
|
||||||
|
neutral: 'default',
|
||||||
|
info: 'info',
|
||||||
|
success: 'success',
|
||||||
|
warning: 'warning',
|
||||||
|
error: 'error',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ChipProps {
|
||||||
|
label: string;
|
||||||
|
tone?: Tone;
|
||||||
|
onDelete?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Chip({ label, tone = 'neutral', onDelete }: ChipProps) {
|
||||||
|
return (
|
||||||
|
<MuiChip
|
||||||
|
label={label}
|
||||||
|
color={TONE_MAP[tone]}
|
||||||
|
onDelete={onDelete}
|
||||||
|
{...(onDelete ? { deleteIcon: <CancelIcon aria-label={`Remove ${label}`} /> } : {})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Chip/index.ts
Normal file
2
packages/design-system/src/components/Chip/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Chip } from './Chip';
|
||||||
|
export type { ChipProps } from './Chip';
|
||||||
@ -0,0 +1,121 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { DataTable } from './DataTable';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
import {
|
||||||
|
useReactTable,
|
||||||
|
getCoreRowModel,
|
||||||
|
createColumnHelper,
|
||||||
|
getSortedRowModel,
|
||||||
|
} from '@tanstack/react-table';
|
||||||
|
import type { SortingState } from '@tanstack/react-table';
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
interface Stock {
|
||||||
|
ticker: string;
|
||||||
|
price: number;
|
||||||
|
change: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Stock[] = [
|
||||||
|
{ ticker: 'SBER', price: 281.5, change: 1.2 },
|
||||||
|
{ ticker: 'GAZP', price: 162.3, change: -0.5 },
|
||||||
|
{ ticker: 'LKOH', price: 7_112.0, change: 2.1 },
|
||||||
|
{ ticker: 'YNDX', price: 5_413.0, change: -1.8 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Stock>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('ticker', { header: 'Тикер' }),
|
||||||
|
columnHelper.accessor('price', { header: 'Цена' }),
|
||||||
|
columnHelper.accessor('change', { header: 'Изменение' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
function SortableTableStory() {
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([]);
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
state: { sorting },
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MoexVibeThemeProvider>
|
||||||
|
<DataTable table={table} caption="Котировки акций" />
|
||||||
|
</MoexVibeThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: Meta<typeof DataTable> = {
|
||||||
|
title: 'Data/DataTable',
|
||||||
|
component: DataTable,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof DataTable>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
render: () => {
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MoexVibeThemeProvider>
|
||||||
|
<DataTable table={table} caption="Котировки акций" />
|
||||||
|
</MoexVibeThemeProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Compact: Story = {
|
||||||
|
render: () => {
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MoexVibeThemeProvider>
|
||||||
|
<DataTable table={table} caption="Котировки акций" density="compact" />
|
||||||
|
</MoexVibeThemeProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Sortable: Story = {
|
||||||
|
render: () => <SortableTableStory />,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Empty: Story = {
|
||||||
|
render: () => {
|
||||||
|
const table = useReactTable({
|
||||||
|
data: [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MoexVibeThemeProvider>
|
||||||
|
<DataTable table={table} caption="Котировки акций" empty={<div>Нет данных</div>} />
|
||||||
|
</MoexVibeThemeProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
render: () => {
|
||||||
|
const table = useReactTable({
|
||||||
|
data: [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MoexVibeThemeProvider>
|
||||||
|
<DataTable table={table} caption="Котировки акций" loading />
|
||||||
|
</MoexVibeThemeProvider>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,97 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { DataTable } from './DataTable';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
import { useReactTable, getCoreRowModel, createColumnHelper } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
interface Item {
|
||||||
|
name: string;
|
||||||
|
price: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: Item[] = [
|
||||||
|
{ name: 'AAPL', price: 250 },
|
||||||
|
{ name: 'GOOGL', price: 180 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const columnHelper = createColumnHelper<Item>();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
columnHelper.accessor('name', { header: 'Name' }),
|
||||||
|
columnHelper.accessor('price', { header: 'Price' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
function TestTable({ caption }: { caption: string }) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MoexVibeThemeProvider>
|
||||||
|
<DataTable table={table} caption={caption} />
|
||||||
|
</MoexVibeThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TestTableWithProps(props: {
|
||||||
|
caption: string;
|
||||||
|
loading?: boolean;
|
||||||
|
empty?: React.ReactNode;
|
||||||
|
density?: 'balanced' | 'compact';
|
||||||
|
}) {
|
||||||
|
const table = useReactTable({
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MoexVibeThemeProvider>
|
||||||
|
<DataTable table={table} {...props} />
|
||||||
|
</MoexVibeThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyTable() {
|
||||||
|
const table = useReactTable({
|
||||||
|
data: [],
|
||||||
|
columns,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<MoexVibeThemeProvider>
|
||||||
|
<DataTable table={table} caption="Empty" empty={<div>No data</div>} />
|
||||||
|
</MoexVibeThemeProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DataTable', () => {
|
||||||
|
it('renders caption', () => {
|
||||||
|
render(<TestTable caption="Test Caption" />);
|
||||||
|
expect(screen.getByText('Test Caption')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders column headers as th elements', () => {
|
||||||
|
render(<TestTable caption="Stocks" />);
|
||||||
|
const headers = screen.getAllByRole('columnheader');
|
||||||
|
expect(headers).toHaveLength(2);
|
||||||
|
expect(headers[0]).toHaveTextContent('Name');
|
||||||
|
expect(headers[1]).toHaveTextContent('Price');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders data rows', () => {
|
||||||
|
render(<TestTable caption="Stocks" />);
|
||||||
|
expect(screen.getByText('AAPL')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('GOOGL')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with compact density', () => {
|
||||||
|
render(<TestTableWithProps caption="Stocks" density="compact" />);
|
||||||
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows empty state when no data', () => {
|
||||||
|
render(<EmptyTable />);
|
||||||
|
expect(screen.getByText('No data')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableContainer,
|
||||||
|
TableHead,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableCell,
|
||||||
|
type TableProps as MuiTableProps,
|
||||||
|
} from '@mui/material';
|
||||||
|
import type { Table as TanStackTable } from '@tanstack/react-table';
|
||||||
|
import { flexRender } from '@tanstack/react-table';
|
||||||
|
|
||||||
|
export type Density = 'balanced' | 'compact';
|
||||||
|
|
||||||
|
export interface DataTableProps<T> {
|
||||||
|
table: TanStackTable<T>;
|
||||||
|
density?: Density;
|
||||||
|
loading?: boolean;
|
||||||
|
empty?: ReactNode;
|
||||||
|
caption: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DENSITY_PADDING: Record<Density, MuiTableProps['size']> = {
|
||||||
|
balanced: 'medium',
|
||||||
|
compact: 'small',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function DataTable<T>({
|
||||||
|
table,
|
||||||
|
density = 'balanced',
|
||||||
|
loading,
|
||||||
|
empty,
|
||||||
|
caption,
|
||||||
|
}: DataTableProps<T>) {
|
||||||
|
const rows = table.getRowModel().rows;
|
||||||
|
|
||||||
|
if (!loading && rows.length === 0 && empty) {
|
||||||
|
return <>{empty}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableContainer>
|
||||||
|
<Table size={DENSITY_PADDING[density]}>
|
||||||
|
<caption>{caption}</caption>
|
||||||
|
<TableHead>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id}>
|
||||||
|
{headerGroup.headers.map((header) => (
|
||||||
|
<TableCell key={header.id} sortDirection={header.column.getIsSorted() || false}>
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHead>
|
||||||
|
<TableBody>
|
||||||
|
{rows.map((row) => (
|
||||||
|
<TableRow key={row.id}>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id}>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/DataTable/index.ts
Normal file
2
packages/design-system/src/components/DataTable/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { DataTable } from './DataTable';
|
||||||
|
export type { DataTableProps, Density } from './DataTable';
|
||||||
@ -0,0 +1,61 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { fn } from '@storybook/test';
|
||||||
|
import { Button } from '../Button/Button';
|
||||||
|
import { Dialog } from './Dialog';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Dialog> = {
|
||||||
|
title: 'Feedback/Dialog',
|
||||||
|
component: Dialog,
|
||||||
|
argTypes: {
|
||||||
|
open: { control: 'boolean' },
|
||||||
|
title: { control: 'text' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Dialog>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
open: true,
|
||||||
|
onClose: fn(),
|
||||||
|
title: 'Подтверждение',
|
||||||
|
children: 'Вы уверены, что хотите удалить этот портфель?',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithActions: Story = {
|
||||||
|
args: {
|
||||||
|
open: true,
|
||||||
|
onClose: fn(),
|
||||||
|
title: 'Удаление портфеля',
|
||||||
|
children: 'Портфель "Акции США" будет безвозвратно удалён',
|
||||||
|
actions: (
|
||||||
|
<div style={{ display: 'flex', gap: 8 }}>
|
||||||
|
<Button variant="secondary" onClick={fn()}>
|
||||||
|
Отмена
|
||||||
|
</Button>
|
||||||
|
<Button variant="danger" onClick={fn()}>
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Interactive: Story = {
|
||||||
|
render: () => {
|
||||||
|
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => setOpen(true)}>Открыть диалог</Button>
|
||||||
|
<Dialog open={open} onClose={() => setOpen(false)} title="Модальное окно">
|
||||||
|
<p>Это интерактивный пример диалога.</p>
|
||||||
|
<p>Нажмите Escape или кликните вне окна для закрытия.</p>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
81
packages/design-system/src/components/Dialog/Dialog.test.tsx
Normal file
81
packages/design-system/src/components/Dialog/Dialog.test.tsx
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Dialog } from './Dialog';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Dialog', () => {
|
||||||
|
it('renders when open', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Dialog open={true} onClose={() => {}} title="Confirm">
|
||||||
|
Are you sure?
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not render when closed', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Dialog open={false} onClose={() => {}} title="Confirm">
|
||||||
|
Are you sure?
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has aria-labelledby pointing to title', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Dialog open={true} onClose={() => {}} title="Confirm">
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
const titleId = dialog.getAttribute('aria-labelledby');
|
||||||
|
expect(titleId).toBeTruthy();
|
||||||
|
const titleEl = document.getElementById(titleId!);
|
||||||
|
expect(titleEl).toHaveTextContent('Confirm');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClose on Escape', async () => {
|
||||||
|
const handleClose = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithTheme(
|
||||||
|
<Dialog open={true} onClose={handleClose} title="Confirm">
|
||||||
|
Are you sure?
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
await user.keyboard('{Escape}');
|
||||||
|
expect(handleClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders title', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Dialog open={true} onClose={() => {}} title="Confirm">
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Confirm')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Dialog open={true} onClose={() => {}} title="Confirm">
|
||||||
|
Are you sure?
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Are you sure?')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders actions', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Dialog open={true} onClose={() => {}} title="Confirm" actions={<button>OK</button>}>
|
||||||
|
Content
|
||||||
|
</Dialog>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
packages/design-system/src/components/Dialog/Dialog.tsx
Normal file
23
packages/design-system/src/components/Dialog/Dialog.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { useId } from 'react';
|
||||||
|
import { Dialog as MuiDialog, DialogTitle, DialogContent, DialogActions } from '@mui/material';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface DialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dialog({ open, onClose, title, children, actions }: DialogProps) {
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MuiDialog open={open} onClose={onClose} aria-labelledby={titleId}>
|
||||||
|
<DialogTitle id={titleId}>{title}</DialogTitle>
|
||||||
|
<DialogContent>{children}</DialogContent>
|
||||||
|
{actions && <DialogActions>{actions}</DialogActions>}
|
||||||
|
</MuiDialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Dialog/index.ts
Normal file
2
packages/design-system/src/components/Dialog/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Dialog } from './Dialog';
|
||||||
|
export type { DialogProps } from './Dialog';
|
||||||
@ -12,7 +12,7 @@ export interface EmptyStateProps {
|
|||||||
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
export function EmptyState({ title, description, action }: EmptyStateProps) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={1} alignItems="center" data-testid="empty-state">
|
<Stack spacing={1} alignItems="center" data-testid="empty-state">
|
||||||
<Heading as="h2">{title}</Heading>
|
<Heading level={2}>{title}</Heading>
|
||||||
{description && <Text>{description}</Text>}
|
{description && <Text>{description}</Text>}
|
||||||
{action && <div>{action}</div>}
|
{action && <div>{action}</div>}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@ -12,7 +12,7 @@ export interface ErrorStateProps {
|
|||||||
export function ErrorState({ title, description, action }: ErrorStateProps) {
|
export function ErrorState({ title, description, action }: ErrorStateProps) {
|
||||||
return (
|
return (
|
||||||
<Stack spacing={1} alignItems="center" data-testid="error-state">
|
<Stack spacing={1} alignItems="center" data-testid="error-state">
|
||||||
<Heading as="h2">{title}</Heading>
|
<Heading level={2}>{title}</Heading>
|
||||||
{description && <Text>{description}</Text>}
|
{description && <Text>{description}</Text>}
|
||||||
{action && <div>{action}</div>}
|
{action && <div>{action}</div>}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@ -22,7 +22,7 @@ export function LoadingState({ title, label, size = 'section' }: LoadingStatePro
|
|||||||
>
|
>
|
||||||
<Stack spacing={spacing} alignItems="center">
|
<Stack spacing={spacing} alignItems="center">
|
||||||
<CircularProgress size={progressSize} />
|
<CircularProgress size={progressSize} />
|
||||||
<Heading as="h2">{title}</Heading>
|
<Heading level={2}>{title}</Heading>
|
||||||
{label && <Text>{label}</Text>}
|
{label && <Text>{label}</Text>}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@ -0,0 +1,39 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Metric } from './Metric';
|
||||||
|
import { PriceChange } from '../PriceChange';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Metric> = {
|
||||||
|
title: 'Data/Metric',
|
||||||
|
component: Metric,
|
||||||
|
argTypes: {
|
||||||
|
label: { control: 'text' },
|
||||||
|
value: { control: 'text' },
|
||||||
|
supportingText: { control: 'text' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Metric>;
|
||||||
|
|
||||||
|
export const Basic: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Общая стоимость',
|
||||||
|
value: '1 234 567 ₽',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithTrend: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Доходность',
|
||||||
|
value: '1 234 567 ₽',
|
||||||
|
trend: <PriceChange value={5.5} />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSupportingText: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Дивиденды',
|
||||||
|
value: '45 000 ₽',
|
||||||
|
supportingText: 'За последний квартал',
|
||||||
|
},
|
||||||
|
};
|
||||||
26
packages/design-system/src/components/Metric/Metric.test.tsx
Normal file
26
packages/design-system/src/components/Metric/Metric.test.tsx
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Metric } from './Metric';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Metric', () => {
|
||||||
|
it('renders label and value', () => {
|
||||||
|
renderWithTheme(<Metric label="Total" value="$1,000" />);
|
||||||
|
expect(screen.getByText('Total')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('$1,000')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders supportingText', () => {
|
||||||
|
renderWithTheme(<Metric label="Total" value="$1,000" supportingText="Since last month" />);
|
||||||
|
expect(screen.getByText('Since last month')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders trend slot', () => {
|
||||||
|
renderWithTheme(<Metric label="Total" value="$1,000" trend={<span>+5%</span>} />);
|
||||||
|
expect(screen.getByText('+5%')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
29
packages/design-system/src/components/Metric/Metric.tsx
Normal file
29
packages/design-system/src/components/Metric/Metric.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Box } from '@mui/material';
|
||||||
|
import { Text } from '../Text';
|
||||||
|
|
||||||
|
export interface MetricProps {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
supportingText?: ReactNode;
|
||||||
|
trend?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Metric({ label, value, supportingText, trend }: MetricProps) {
|
||||||
|
return (
|
||||||
|
<Box>
|
||||||
|
<Text variant="label" tone="secondary">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 1 }}>
|
||||||
|
<Text variant="numeric">{value}</Text>
|
||||||
|
{trend && <Box sx={{ display: 'flex', alignItems: 'baseline' }}>{trend}</Box>}
|
||||||
|
</Box>
|
||||||
|
{supportingText && (
|
||||||
|
<Text variant="caption" tone="muted">
|
||||||
|
{supportingText}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Metric/index.ts
Normal file
2
packages/design-system/src/components/Metric/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Metric } from './Metric';
|
||||||
|
export type { MetricProps } from './Metric';
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Money } from './Money';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Money> = {
|
||||||
|
title: 'Data/Money',
|
||||||
|
component: Money,
|
||||||
|
argTypes: {
|
||||||
|
value: { control: 'number' },
|
||||||
|
currency: { control: 'text' },
|
||||||
|
locale: { control: 'text' },
|
||||||
|
signDisplay: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['auto', 'always', 'never'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Money>;
|
||||||
|
|
||||||
|
export const Rubles: Story = {
|
||||||
|
args: {
|
||||||
|
value: 1234.56,
|
||||||
|
currency: 'RUB',
|
||||||
|
locale: 'ru-RU',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Dollars: Story = {
|
||||||
|
args: {
|
||||||
|
value: 1234.56,
|
||||||
|
currency: 'USD',
|
||||||
|
locale: 'en-US',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Negative: Story = {
|
||||||
|
args: {
|
||||||
|
value: -500,
|
||||||
|
currency: 'RUB',
|
||||||
|
locale: 'ru-RU',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithSign: Story = {
|
||||||
|
args: {
|
||||||
|
value: 500,
|
||||||
|
currency: 'RUB',
|
||||||
|
locale: 'ru-RU',
|
||||||
|
signDisplay: 'always',
|
||||||
|
},
|
||||||
|
};
|
||||||
35
packages/design-system/src/components/Money/Money.test.tsx
Normal file
35
packages/design-system/src/components/Money/Money.test.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Money } from './Money';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Money', () => {
|
||||||
|
it('formats with default locale and currency', () => {
|
||||||
|
renderWithTheme(<Money value={1000} />);
|
||||||
|
expect(screen.getByText('1 000,00 ₽')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats with custom currency', () => {
|
||||||
|
renderWithTheme(<Money value={1000} currency="USD" />);
|
||||||
|
expect(screen.getByText('1 000,00 $')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats with custom locale', () => {
|
||||||
|
renderWithTheme(<Money value={1000} locale="en-US" />);
|
||||||
|
expect(screen.getByText('RUB 1,000.00')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows sign with signDisplay always', () => {
|
||||||
|
renderWithTheme(<Money value={500} signDisplay="always" />);
|
||||||
|
expect(screen.getByText('+500,00 ₽')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides sign with signDisplay never', () => {
|
||||||
|
renderWithTheme(<Money value={-500} signDisplay="never" />);
|
||||||
|
expect(screen.getByText('500,00 ₽')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
23
packages/design-system/src/components/Money/Money.tsx
Normal file
23
packages/design-system/src/components/Money/Money.tsx
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface MoneyProps {
|
||||||
|
value: number;
|
||||||
|
currency?: string;
|
||||||
|
locale?: string;
|
||||||
|
signDisplay?: 'auto' | 'always' | 'never';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Money({
|
||||||
|
value,
|
||||||
|
currency = 'RUB',
|
||||||
|
locale = 'ru-RU',
|
||||||
|
signDisplay = 'auto',
|
||||||
|
}: MoneyProps) {
|
||||||
|
const formatted = new Intl.NumberFormat(locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
signDisplay,
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
return formatted as unknown as ReactNode;
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Money/index.ts
Normal file
2
packages/design-system/src/components/Money/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Money } from './Money';
|
||||||
|
export type { MoneyProps } from './Money';
|
||||||
@ -0,0 +1,41 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { PriceChange } from './PriceChange';
|
||||||
|
|
||||||
|
const meta: Meta<typeof PriceChange> = {
|
||||||
|
title: 'Data/PriceChange',
|
||||||
|
component: PriceChange,
|
||||||
|
argTypes: {
|
||||||
|
value: { control: 'number' },
|
||||||
|
format: { control: 'select', options: ['percent', 'money'] },
|
||||||
|
currency: { control: 'text' },
|
||||||
|
locale: { control: 'text' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof PriceChange>;
|
||||||
|
|
||||||
|
export const Positive: Story = {
|
||||||
|
args: {
|
||||||
|
value: 5.5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Negative: Story = {
|
||||||
|
args: {
|
||||||
|
value: -3.2,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Flat: Story = {
|
||||||
|
args: {
|
||||||
|
value: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PositiveMoney: Story = {
|
||||||
|
args: {
|
||||||
|
value: 150,
|
||||||
|
format: 'money',
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('PriceChange', () => {
|
||||||
|
it('shows up indicator for positive value', () => {
|
||||||
|
renderWithTheme(<PriceChange value={5.5} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText((content) => content.includes('↑') && content.includes('+5,50')),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows down indicator for negative value', () => {
|
||||||
|
renderWithTheme(<PriceChange value={-3.2} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText((content) => content.includes('↓') && content.includes('3,20')),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows flat indicator for zero value', () => {
|
||||||
|
renderWithTheme(<PriceChange value={0} />);
|
||||||
|
expect(
|
||||||
|
screen.getByText((content) => content.includes('→') && content.includes('0,00')),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats as money', () => {
|
||||||
|
renderWithTheme(<PriceChange value={150} format="money" />);
|
||||||
|
expect(
|
||||||
|
screen.getByText((content) => content.includes('↑') && content.includes('150,00')),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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)} %`
|
||||||
|
) : (
|
||||||
|
<Money value={value} currency={currency} locale={locale} signDisplay="always" />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Text tone={getTone(value)} variant="numeric" aria-label={`${getDirection(value)} ${value}`}>
|
||||||
|
{getDirection(value)} {formattedValue}
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export { PriceChange } from './PriceChange';
|
||||||
|
export type { PriceChangeProps } from './PriceChange';
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Progress } from './Progress';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Progress> = {
|
||||||
|
title: 'Feedback/Progress',
|
||||||
|
component: Progress,
|
||||||
|
argTypes: {
|
||||||
|
label: { control: 'text' },
|
||||||
|
value: { control: 'number', min: 0, max: 100 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Progress>;
|
||||||
|
|
||||||
|
export const Indeterminate: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Загрузка данных',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Determinate: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Загрузка данных',
|
||||||
|
value: 60,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AlmostComplete: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Экспорт портфеля',
|
||||||
|
value: 95,
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -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(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Progress', () => {
|
||||||
|
it('has accessible name via label', () => {
|
||||||
|
renderWithTheme(<Progress label="Loading" />);
|
||||||
|
const bar = screen.getByRole('progressbar');
|
||||||
|
expect(bar).toHaveAccessibleName('Loading');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders indeterminate by default (no value)', () => {
|
||||||
|
renderWithTheme(<Progress label="Loading" />);
|
||||||
|
const bar = screen.getByRole('progressbar');
|
||||||
|
expect(bar).not.toHaveAttribute('aria-valuenow');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders determinate with value', () => {
|
||||||
|
renderWithTheme(<Progress label="Uploading" value={60} />);
|
||||||
|
const bar = screen.getByRole('progressbar');
|
||||||
|
expect(bar).toHaveAttribute('aria-valuenow', '60');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has aria-valuemin 0', () => {
|
||||||
|
renderWithTheme(<Progress label="Uploading" value={60} />);
|
||||||
|
const bar = screen.getByRole('progressbar');
|
||||||
|
expect(bar).toHaveAttribute('aria-valuemin', '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('has aria-valuemax 100', () => {
|
||||||
|
renderWithTheme(<Progress label="Uploading" value={60} />);
|
||||||
|
const bar = screen.getByRole('progressbar');
|
||||||
|
expect(bar).toHaveAttribute('aria-valuemax', '100');
|
||||||
|
});
|
||||||
|
});
|
||||||
25
packages/design-system/src/components/Progress/Progress.tsx
Normal file
25
packages/design-system/src/components/Progress/Progress.tsx
Normal file
@ -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 <LinearProgress {...props} />;
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Progress/index.ts
Normal file
2
packages/design-system/src/components/Progress/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Progress } from './Progress';
|
||||||
|
export type { ProgressProps } from './Progress';
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { fn } from '@storybook/test';
|
||||||
|
import { Select } from './Select';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Select> = {
|
||||||
|
title: 'Inputs/Select',
|
||||||
|
component: Select,
|
||||||
|
argTypes: {
|
||||||
|
label: { control: 'text' },
|
||||||
|
error: { control: 'boolean' },
|
||||||
|
disabled: { control: 'boolean' },
|
||||||
|
helperText: { control: 'text' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Select>;
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
61
packages/design-system/src/components/Select/Select.test.tsx
Normal file
61
packages/design-system/src/components/Select/Select.test.tsx
Normal file
@ -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(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = [
|
||||||
|
{ value: 'usd', label: 'USD' },
|
||||||
|
{ value: 'eur', label: 'EUR' },
|
||||||
|
{ value: 'rub', label: 'RUB', disabled: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Select', () => {
|
||||||
|
it('renders label', () => {
|
||||||
|
renderWithTheme(<Select label="Currency" value="" onChange={() => {}} options={options} />);
|
||||||
|
expect(screen.getByLabelText('Currency')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders options', () => {
|
||||||
|
renderWithTheme(<Select label="Currency" value="" onChange={() => {}} 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(<Select label="Currency" value="" onChange={handleChange} options={options} />);
|
||||||
|
const button = screen.getByLabelText('Currency');
|
||||||
|
await user.click(button);
|
||||||
|
const menuItem = await screen.findByText('USD');
|
||||||
|
await user.click(menuItem);
|
||||||
|
expect(handleChange).toHaveBeenCalledWith('usd');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Select
|
||||||
|
label="Currency"
|
||||||
|
value=""
|
||||||
|
onChange={() => {}}
|
||||||
|
options={options}
|
||||||
|
error
|
||||||
|
helperText="Required"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Required')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('can be disabled', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Select label="Currency" value="" onChange={() => {}} options={options} disabled />,
|
||||||
|
);
|
||||||
|
const button = screen.getByLabelText('Currency');
|
||||||
|
expect(button).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
56
packages/design-system/src/components/Select/Select.tsx
Normal file
56
packages/design-system/src/components/Select/Select.tsx
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Select as MuiSelect,
|
||||||
|
MenuItem,
|
||||||
|
FormHelperText,
|
||||||
|
} from '@mui/material';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface SelectOption {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectProps {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
options: SelectOption[];
|
||||||
|
error?: boolean;
|
||||||
|
helperText?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
error,
|
||||||
|
helperText,
|
||||||
|
disabled,
|
||||||
|
}: SelectProps) {
|
||||||
|
const labelId = `${label.replace(/\s+/g, '-')}-label`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FormControl error={error} disabled={disabled} fullWidth>
|
||||||
|
<InputLabel id={labelId}>{label}</InputLabel>
|
||||||
|
<MuiSelect
|
||||||
|
labelId={labelId}
|
||||||
|
label={label}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value as string)}
|
||||||
|
>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem key={opt.value} value={opt.value} disabled={opt.disabled}>
|
||||||
|
{opt.label}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</MuiSelect>
|
||||||
|
{helperText && <FormHelperText>{helperText}</FormHelperText>}
|
||||||
|
</FormControl>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Select/index.ts
Normal file
2
packages/design-system/src/components/Select/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Select } from './Select';
|
||||||
|
export type { SelectProps, SelectOption } from './Select';
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Skeleton> = {
|
||||||
|
title: 'Feedback/Skeleton',
|
||||||
|
component: Skeleton,
|
||||||
|
argTypes: {
|
||||||
|
shape: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['text', 'rectangular', 'rounded', 'circular'],
|
||||||
|
},
|
||||||
|
width: { control: 'number' },
|
||||||
|
height: { control: 'number' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Skeleton>;
|
||||||
|
|
||||||
|
export const Text: Story = {
|
||||||
|
args: {
|
||||||
|
shape: 'text',
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Rectangular: Story = {
|
||||||
|
args: {
|
||||||
|
shape: 'rectangular',
|
||||||
|
width: 300,
|
||||||
|
height: 200,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Rounded: Story = {
|
||||||
|
args: {
|
||||||
|
shape: 'rounded',
|
||||||
|
width: 300,
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Circular: Story = {
|
||||||
|
args: {
|
||||||
|
shape: 'circular',
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CardSkeleton: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, width: 300 }}>
|
||||||
|
<Skeleton shape="rectangular" width={300} height={160} />
|
||||||
|
<Skeleton shape="text" width={200} />
|
||||||
|
<Skeleton shape="text" width={260} />
|
||||||
|
<Skeleton shape="text" width={140} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Skeleton } from './Skeleton';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Skeleton', () => {
|
||||||
|
it('has aria-hidden', () => {
|
||||||
|
renderWithTheme(<Skeleton shape="text" />);
|
||||||
|
const el = screen.getByTestId('skeleton');
|
||||||
|
expect(el).toHaveAttribute('aria-hidden', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with text shape by default', () => {
|
||||||
|
renderWithTheme(<Skeleton />);
|
||||||
|
const el = screen.getByTestId('skeleton');
|
||||||
|
expect(el.classList.contains('MuiSkeleton-text')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with rectangular shape', () => {
|
||||||
|
renderWithTheme(<Skeleton shape="rectangular" />);
|
||||||
|
const el = screen.getByTestId('skeleton');
|
||||||
|
expect(el.classList.contains('MuiSkeleton-rectangular')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with rounded shape', () => {
|
||||||
|
renderWithTheme(<Skeleton shape="rounded" />);
|
||||||
|
const el = screen.getByTestId('skeleton');
|
||||||
|
expect(el.classList.contains('MuiSkeleton-rounded')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with circular shape', () => {
|
||||||
|
renderWithTheme(<Skeleton shape="circular" />);
|
||||||
|
const el = screen.getByTestId('skeleton');
|
||||||
|
expect(el.classList.contains('MuiSkeleton-circular')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies width and height', () => {
|
||||||
|
renderWithTheme(<Skeleton shape="text" width={200} height={20} />);
|
||||||
|
const el = screen.getByTestId('skeleton');
|
||||||
|
expect(el).toHaveStyle({ width: '200px', height: '20px' });
|
||||||
|
});
|
||||||
|
});
|
||||||
29
packages/design-system/src/components/Skeleton/Skeleton.tsx
Normal file
29
packages/design-system/src/components/Skeleton/Skeleton.tsx
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { Skeleton as MuiSkeleton } from '@mui/material';
|
||||||
|
import type { SkeletonProps as MuiSkeletonProps } from '@mui/material';
|
||||||
|
|
||||||
|
type Shape = 'text' | 'rectangular' | 'rounded' | 'circular';
|
||||||
|
|
||||||
|
const SHAPE_MAP: Record<Shape, MuiSkeletonProps['variant']> = {
|
||||||
|
text: 'text',
|
||||||
|
rectangular: 'rectangular',
|
||||||
|
rounded: 'rounded',
|
||||||
|
circular: 'circular',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SkeletonProps {
|
||||||
|
width?: number | string;
|
||||||
|
height?: number | string;
|
||||||
|
shape?: Shape;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Skeleton({ width, height, shape = 'text' }: SkeletonProps) {
|
||||||
|
return (
|
||||||
|
<MuiSkeleton
|
||||||
|
variant={SHAPE_MAP[shape]}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
aria-hidden={true}
|
||||||
|
data-testid="skeleton"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Skeleton/index.ts
Normal file
2
packages/design-system/src/components/Skeleton/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Skeleton } from './Skeleton';
|
||||||
|
export type { SkeletonProps } from './Skeleton';
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Surface } from './Surface';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Surface> = {
|
||||||
|
title: 'Surfaces/Surface',
|
||||||
|
component: Surface,
|
||||||
|
argTypes: {
|
||||||
|
padding: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['none', 'sm', 'md', 'lg'],
|
||||||
|
},
|
||||||
|
elevation: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['none', 'sm', 'md'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Surface>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
padding: 'md',
|
||||||
|
elevation: 'none',
|
||||||
|
children: 'Содержимое поверхности',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Elevated: Story = {
|
||||||
|
args: {
|
||||||
|
padding: 'md',
|
||||||
|
elevation: 'md',
|
||||||
|
children: 'Поверхность с тенью',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NoPadding: Story = {
|
||||||
|
args: {
|
||||||
|
padding: 'none',
|
||||||
|
elevation: 'sm',
|
||||||
|
children: 'Поверхность без отступов',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LargePadding: Story = {
|
||||||
|
args: {
|
||||||
|
padding: 'lg',
|
||||||
|
elevation: 'sm',
|
||||||
|
children: 'Поверхность с большими отступами',
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Surface } from './Surface';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Surface', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
renderWithTheme(<Surface>Content</Surface>);
|
||||||
|
expect(screen.getByText('Content')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies padding md by default', () => {
|
||||||
|
renderWithTheme(<Surface>Content</Surface>);
|
||||||
|
const el = screen.getByText('Content');
|
||||||
|
expect(el.classList.contains('MuiPaper-root')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies padding none', () => {
|
||||||
|
renderWithTheme(<Surface padding="none">Content</Surface>);
|
||||||
|
const el = screen.getByText('Content');
|
||||||
|
expect(el.classList.contains('MuiPaper-root')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies elevation none by default', () => {
|
||||||
|
renderWithTheme(<Surface>Content</Surface>);
|
||||||
|
const el = screen.getByText('Content');
|
||||||
|
expect(el.classList.contains('MuiPaper-elevation0')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies elevation md', () => {
|
||||||
|
renderWithTheme(<Surface elevation="md">Content</Surface>);
|
||||||
|
const el = screen.getByText('Content');
|
||||||
|
expect(el.classList.contains('MuiPaper-elevation2')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts className and style props', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Surface className="custom" style={{ margin: 4 }}>
|
||||||
|
Content
|
||||||
|
</Surface>,
|
||||||
|
);
|
||||||
|
const el = screen.getByText('Content');
|
||||||
|
expect(el.classList.contains('custom')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
32
packages/design-system/src/components/Surface/Surface.tsx
Normal file
32
packages/design-system/src/components/Surface/Surface.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Paper, type PaperProps } from '@mui/material';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type PaddingSize = 'none' | 'sm' | 'md' | 'lg';
|
||||||
|
type ElevationLevel = 'none' | 'sm' | 'md';
|
||||||
|
|
||||||
|
const PADDING_MAP: Record<PaddingSize, number> = {
|
||||||
|
none: 0,
|
||||||
|
sm: 1,
|
||||||
|
md: 2,
|
||||||
|
lg: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ELEVATION_MAP: Record<ElevationLevel, number> = {
|
||||||
|
none: 0,
|
||||||
|
sm: 1,
|
||||||
|
md: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface SurfaceProps extends Omit<PaperProps, 'elevation' | 'sx'> {
|
||||||
|
padding?: PaddingSize;
|
||||||
|
elevation?: ElevationLevel;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Surface({ padding = 'md', elevation = 'none', children, ...props }: SurfaceProps) {
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: PADDING_MAP[padding] }} elevation={ELEVATION_MAP[elevation]} {...props}>
|
||||||
|
{children}
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Surface/index.ts
Normal file
2
packages/design-system/src/components/Surface/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Surface } from './Surface';
|
||||||
|
export type { SurfaceProps } from './Surface';
|
||||||
@ -0,0 +1,56 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { TextField } from './TextField';
|
||||||
|
|
||||||
|
const meta: Meta<typeof TextField> = {
|
||||||
|
title: 'Inputs/TextField',
|
||||||
|
component: TextField,
|
||||||
|
argTypes: {
|
||||||
|
label: { control: 'text' },
|
||||||
|
helperText: { control: 'text' },
|
||||||
|
error: { control: 'boolean' },
|
||||||
|
disabled: { control: 'boolean' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof TextField>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Название',
|
||||||
|
placeholder: 'Введите название',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WithHelperText: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Email',
|
||||||
|
helperText: 'Введите ваш email адрес',
|
||||||
|
placeholder: 'user@example.com',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Error: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Пароль',
|
||||||
|
error: true,
|
||||||
|
helperText: 'Обязательное поле',
|
||||||
|
placeholder: 'Введите пароль',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Заблокировано',
|
||||||
|
disabled: true,
|
||||||
|
value: 'Недоступно для редактирования',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LongRussianText: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Описание инвестиционной стратегии',
|
||||||
|
helperText: 'Максимальная длина — 1000 символов',
|
||||||
|
placeholder: 'Опишите вашу инвестиционную стратегию максимально подробно...',
|
||||||
|
},
|
||||||
|
};
|
||||||
@ -0,0 +1,44 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { TextField } from './TextField';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('TextField', () => {
|
||||||
|
it('renders label and associates with input', () => {
|
||||||
|
renderWithTheme(<TextField label="Name" />);
|
||||||
|
const input = screen.getByLabelText('Name');
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders helper text', () => {
|
||||||
|
renderWithTheme(<TextField label="Name" helperText="Enter your name" />);
|
||||||
|
expect(screen.getByText('Enter your name')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows error state', () => {
|
||||||
|
renderWithTheme(<TextField label="Name" error helperText="Required" />);
|
||||||
|
const input = screen.getByLabelText('Name');
|
||||||
|
expect(input).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders helper text with error', () => {
|
||||||
|
renderWithTheme(<TextField label="Name" error helperText="Required" />);
|
||||||
|
expect(screen.getByText('Required')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pass color, variant, size or sx to DOM', () => {
|
||||||
|
renderWithTheme(<TextField label="Name" />);
|
||||||
|
const input = screen.getByLabelText('Name');
|
||||||
|
expect(input).not.toHaveAttribute('color');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts className and style props', () => {
|
||||||
|
renderWithTheme(<TextField label="Name" className="custom" style={{ margin: 4 }} />);
|
||||||
|
const wrapper = screen.getByLabelText('Name').closest('.MuiTextField-root')!;
|
||||||
|
expect(wrapper.classList.contains('custom')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import { TextField as MuiTextField, type TextFieldProps as MuiTextFieldProps } from '@mui/material';
|
||||||
|
|
||||||
|
export interface TextFieldProps extends Omit<
|
||||||
|
MuiTextFieldProps,
|
||||||
|
'variant' | 'color' | 'size' | 'sx'
|
||||||
|
> {
|
||||||
|
label: string;
|
||||||
|
helperText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextField({ label, helperText, error, ...props }: TextFieldProps) {
|
||||||
|
return (
|
||||||
|
<MuiTextField
|
||||||
|
label={label}
|
||||||
|
helperText={helperText}
|
||||||
|
error={error}
|
||||||
|
variant="outlined"
|
||||||
|
size="medium"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/TextField/index.ts
Normal file
2
packages/design-system/src/components/TextField/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { TextField } from './TextField';
|
||||||
|
export type { TextFieldProps } from './TextField';
|
||||||
15
packages/design-system/src/test/setup.ts
Normal file
15
packages/design-system/src/test/setup.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import '@testing-library/jest-dom';
|
||||||
|
|
||||||
|
Object.defineProperty(window, 'matchMedia', {
|
||||||
|
writable: true,
|
||||||
|
value: (query: string) => ({
|
||||||
|
matches: false,
|
||||||
|
media: query,
|
||||||
|
onchange: null,
|
||||||
|
addListener: () => {},
|
||||||
|
removeListener: () => {},
|
||||||
|
addEventListener: () => {},
|
||||||
|
removeEventListener: () => {},
|
||||||
|
dispatchEvent: () => false,
|
||||||
|
}),
|
||||||
|
});
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import { type ReactNode } from 'react';
|
import { type ReactNode } from 'react';
|
||||||
import { ThemeProvider } from '@mui/material/styles';
|
import { ThemeProvider } from '@mui/material/styles';
|
||||||
import CssBaseline from '@mui/material/CssBaseline';
|
import CssBaseline from '@mui/material/CssBaseline';
|
||||||
import { Experimental_CssVarsProvider as CssVarsProvider } from '@mui/material/styles';
|
|
||||||
import { createMoexVibeTheme } from './createMoexVibeTheme';
|
import { createMoexVibeTheme } from './createMoexVibeTheme';
|
||||||
|
|
||||||
const theme = createMoexVibeTheme();
|
const theme = createMoexVibeTheme();
|
||||||
@ -12,9 +11,9 @@ export interface MoexVibeThemeProviderProps {
|
|||||||
|
|
||||||
export function MoexVibeThemeProvider({ children }: MoexVibeThemeProviderProps) {
|
export function MoexVibeThemeProvider({ children }: MoexVibeThemeProviderProps) {
|
||||||
return (
|
return (
|
||||||
<CssVarsProvider theme={theme}>
|
<ThemeProvider theme={theme}>
|
||||||
<CssBaseline />
|
<CssBaseline />
|
||||||
{children}
|
{children}
|
||||||
</CssVarsProvider>
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
25
packages/design-system/tsconfig.json
Normal file
25
packages/design-system/tsconfig.json
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src",
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"allowImportingTsExtensions": false,
|
||||||
|
"noEmit": false,
|
||||||
|
"noUnusedLocals": false,
|
||||||
|
"noUnusedParameters": false,
|
||||||
|
"noFallthroughCasesInSwitch": true
|
||||||
|
},
|
||||||
|
"include": ["src"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx", "src/**/*.stories.ts", "src/**/*.stories.tsx"]
|
||||||
|
}
|
||||||
13
packages/design-system/vitest.config.ts
Normal file
13
packages/design-system/vitest.config.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
name: 'unit',
|
||||||
|
environment: 'jsdom',
|
||||||
|
setupFiles: ['./src/test/setup.ts'],
|
||||||
|
include: ['src/**/*.test.{ts,tsx}'],
|
||||||
|
exclude: ['src/**/*.stories.test.{ts,tsx}'],
|
||||||
|
globals: true,
|
||||||
|
passWithNoTests: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user