codex/design-system-foundation #33
105
packages/design-system/src/components/Button/Button.stories.tsx
Normal file
105
packages/design-system/src/components/Button/Button.stories.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Button } from './Button';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Button> = {
|
||||||
|
title: 'Actions/Button',
|
||||||
|
component: Button,
|
||||||
|
argTypes: {
|
||||||
|
variant: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['primary', 'secondary', 'tertiary', 'danger'],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['small', 'medium'],
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
disabled: {
|
||||||
|
control: 'boolean',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Button>;
|
||||||
|
|
||||||
|
export const Primary: Story = {
|
||||||
|
args: {
|
||||||
|
variant: 'primary',
|
||||||
|
children: 'Купить',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Secondary: Story = {
|
||||||
|
args: {
|
||||||
|
variant: 'secondary',
|
||||||
|
children: 'Отмена',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Tertiary: Story = {
|
||||||
|
args: {
|
||||||
|
variant: 'tertiary',
|
||||||
|
children: 'Подробнее',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Danger: Story = {
|
||||||
|
args: {
|
||||||
|
variant: 'danger',
|
||||||
|
children: 'Удалить портфель',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Small: Story = {
|
||||||
|
args: {
|
||||||
|
size: 'small',
|
||||||
|
children: 'Применить',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Loading: Story = {
|
||||||
|
args: {
|
||||||
|
loading: true,
|
||||||
|
children: 'Отправка...',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Disabled: Story = {
|
||||||
|
args: {
|
||||||
|
disabled: true,
|
||||||
|
children: 'Недоступно',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllVariants: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: 12, flexWrap: 'wrap' }}>
|
||||||
|
<Button variant="primary">Купить</Button>
|
||||||
|
<Button variant="secondary">Отмена</Button>
|
||||||
|
<Button variant="tertiary">Подробнее</Button>
|
||||||
|
<Button variant="danger">Удалить</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllSizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
|
<Button size="small">Маленькая</Button>
|
||||||
|
<Button size="medium">Средняя</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllStates: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
|
<Button>Обычная</Button>
|
||||||
|
<Button loading>Загрузка</Button>
|
||||||
|
<Button disabled>Блокирована</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
105
packages/design-system/src/components/Button/Button.test.tsx
Normal file
105
packages/design-system/src/components/Button/Button.test.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { Button } from './Button';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Button', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
renderWithTheme(<Button>Click me</Button>);
|
||||||
|
expect(screen.getByText('Click me')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders as a button element by default', () => {
|
||||||
|
renderWithTheme(<Button>Click</Button>);
|
||||||
|
expect(screen.getByRole('button', { name: /click/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders primary variant by default', () => {
|
||||||
|
renderWithTheme(<Button>Click</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.classList.contains('MuiButton-contained')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders secondary variant', () => {
|
||||||
|
renderWithTheme(<Button variant="secondary">Click</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.classList.contains('MuiButton-outlined')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders tertiary variant', () => {
|
||||||
|
renderWithTheme(<Button variant="tertiary">Click</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.classList.contains('MuiButton-text')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders danger variant with contained style', () => {
|
||||||
|
renderWithTheme(<Button variant="danger">Delete</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.classList.contains('MuiButton-contained')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pass color prop to DOM', () => {
|
||||||
|
renderWithTheme(<Button>Click</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn).not.toHaveAttribute('color');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts className and style props', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Button className="custom" style={{ margin: 4 }}>
|
||||||
|
Click
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByText('Click');
|
||||||
|
expect(btn.classList.contains('custom')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls onClick when clicked', async () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
const user = userEvent.setup();
|
||||||
|
renderWithTheme(<Button onClick={handleClick}>Click</Button>);
|
||||||
|
await user.click(screen.getByRole('button'));
|
||||||
|
expect(handleClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows aria-busy when loading', () => {
|
||||||
|
renderWithTheme(<Button loading>Save</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn).toHaveAttribute('aria-busy', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables button when loading', () => {
|
||||||
|
renderWithTheme(<Button loading>Save</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not call onClick when loading', () => {
|
||||||
|
const handleClick = vi.fn();
|
||||||
|
const { container } = renderWithTheme(
|
||||||
|
<Button loading onClick={handleClick}>
|
||||||
|
Save
|
||||||
|
</Button>,
|
||||||
|
);
|
||||||
|
const btn = container.querySelector('button')!;
|
||||||
|
btn.click();
|
||||||
|
expect(handleClick).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders small size', () => {
|
||||||
|
renderWithTheme(<Button size="small">Click</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.classList.contains('MuiButton-sizeSmall')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders medium size by default', () => {
|
||||||
|
renderWithTheme(<Button>Click</Button>);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.classList.contains('MuiButton-sizeMedium')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
48
packages/design-system/src/components/Button/Button.tsx
Normal file
48
packages/design-system/src/components/Button/Button.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
Button as MuiButton,
|
||||||
|
CircularProgress,
|
||||||
|
type ButtonProps as MuiButtonProps,
|
||||||
|
} from '@mui/material';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type ActionVariant = 'primary' | 'secondary' | 'tertiary' | 'danger';
|
||||||
|
|
||||||
|
export interface ButtonProps extends Omit<MuiButtonProps, 'variant' | 'color' | 'size' | 'sx'> {
|
||||||
|
variant?: ActionVariant;
|
||||||
|
size?: 'small' | 'medium';
|
||||||
|
loading?: boolean;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_MAP: Record<ActionVariant, MuiButtonProps['variant']> = {
|
||||||
|
primary: 'contained',
|
||||||
|
secondary: 'outlined',
|
||||||
|
tertiary: 'text',
|
||||||
|
danger: 'contained',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = 'primary',
|
||||||
|
size = 'medium',
|
||||||
|
loading = false,
|
||||||
|
disabled,
|
||||||
|
children,
|
||||||
|
...props
|
||||||
|
}: ButtonProps) {
|
||||||
|
const muiVariant = VARIANT_MAP[variant];
|
||||||
|
const muiColor = variant === 'danger' ? 'error' : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<MuiButton
|
||||||
|
variant={muiVariant}
|
||||||
|
size={size}
|
||||||
|
color={muiColor}
|
||||||
|
disabled={disabled || loading}
|
||||||
|
aria-busy={loading ? true : undefined}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{loading && <CircularProgress size={16} sx={{ mr: 1 }} />}
|
||||||
|
{children}
|
||||||
|
</MuiButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Button/index.ts
Normal file
2
packages/design-system/src/components/Button/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Button } from './Button';
|
||||||
|
export type { ButtonProps } from './Button';
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Heading } from './Heading';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Heading> = {
|
||||||
|
title: 'Typography/Heading',
|
||||||
|
component: Heading,
|
||||||
|
argTypes: {
|
||||||
|
level: {
|
||||||
|
control: 'select',
|
||||||
|
options: [1, 2, 3, 4, 5, 6],
|
||||||
|
},
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['display', 'title', 'section', 'subsection'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Heading>;
|
||||||
|
|
||||||
|
export const Level1: Story = {
|
||||||
|
args: {
|
||||||
|
level: 1,
|
||||||
|
children: 'Обзор рынка',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Level2: Story = {
|
||||||
|
args: {
|
||||||
|
level: 2,
|
||||||
|
children: 'Акции',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Level3: Story = {
|
||||||
|
args: {
|
||||||
|
level: 3,
|
||||||
|
children: 'Голубые фишки',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DisplaySize: Story = {
|
||||||
|
args: {
|
||||||
|
level: 1,
|
||||||
|
size: 'display',
|
||||||
|
children: 'Московская Биржа',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TitleSize: Story = {
|
||||||
|
args: {
|
||||||
|
level: 2,
|
||||||
|
size: 'title',
|
||||||
|
children: 'Индекс Мосбиржи обновил максимум',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SectionSize: Story = {
|
||||||
|
args: {
|
||||||
|
level: 3,
|
||||||
|
size: 'section',
|
||||||
|
children: 'Нефтегазовый сектор',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SubsectionSize: Story = {
|
||||||
|
args: {
|
||||||
|
level: 4,
|
||||||
|
size: 'subsection',
|
||||||
|
children: 'Лукойл',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllLevels: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<Heading level={1} size="title">
|
||||||
|
H1 — Заголовок страницы
|
||||||
|
</Heading>
|
||||||
|
<Heading level={2} size="section">
|
||||||
|
H2 — Раздел
|
||||||
|
</Heading>
|
||||||
|
<Heading level={3} size="subsection">
|
||||||
|
H3 — Подраздел
|
||||||
|
</Heading>
|
||||||
|
<Heading level={4}>H4 — Группа</Heading>
|
||||||
|
<Heading level={5}>H5 — Элемент</Heading>
|
||||||
|
<Heading level={6}>H6 — Мелкий заголовок</Heading>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
@ -0,0 +1,55 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Heading } from './Heading';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Heading', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
renderWithTheme(<Heading level={1}>Title</Heading>);
|
||||||
|
expect(screen.getByText('Title')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders h1 for level 1', () => {
|
||||||
|
renderWithTheme(<Heading level={1}>H1</Heading>);
|
||||||
|
expect(screen.getByRole('heading', { level: 1 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders h2 for level 2', () => {
|
||||||
|
renderWithTheme(<Heading level={2}>H2</Heading>);
|
||||||
|
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders h3 for level 3', () => {
|
||||||
|
renderWithTheme(<Heading level={3}>H3</Heading>);
|
||||||
|
expect(screen.getByRole('heading', { level: 3 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders h4 for level 4', () => {
|
||||||
|
renderWithTheme(<Heading level={4}>H4</Heading>);
|
||||||
|
expect(screen.getByRole('heading', { level: 4 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders h5 for level 5', () => {
|
||||||
|
renderWithTheme(<Heading level={5}>H5</Heading>);
|
||||||
|
expect(screen.getByRole('heading', { level: 5 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders h6 for level 6', () => {
|
||||||
|
renderWithTheme(<Heading level={6}>H6</Heading>);
|
||||||
|
expect(screen.getByRole('heading', { level: 6 })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts className and style props', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Heading level={1} className="custom" style={{ margin: 8 }}>
|
||||||
|
Hello
|
||||||
|
</Heading>,
|
||||||
|
);
|
||||||
|
const el = screen.getByText('Hello');
|
||||||
|
expect(el.classList.contains('custom')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
25
packages/design-system/src/components/Heading/Heading.tsx
Normal file
25
packages/design-system/src/components/Heading/Heading.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Typography, type TypographyProps } from '@mui/material';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Size = 'display' | 'title' | 'section' | 'subsection';
|
||||||
|
|
||||||
|
export interface HeadingProps extends Omit<TypographyProps, 'variant' | 'color' | 'sx'> {
|
||||||
|
level: 1 | 2 | 3 | 4 | 5 | 6;
|
||||||
|
size?: Size;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SIZE_VARIANT: Record<Size, TypographyProps['variant']> = {
|
||||||
|
display: 'h1',
|
||||||
|
title: 'h2',
|
||||||
|
section: 'h3',
|
||||||
|
subsection: 'h4',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Heading({ level, size = 'title', children, ...props }: HeadingProps) {
|
||||||
|
return (
|
||||||
|
<Typography variant={SIZE_VARIANT[size]} component={`h${level}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Heading/index.ts
Normal file
2
packages/design-system/src/components/Heading/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Heading } from './Heading';
|
||||||
|
export type { HeadingProps } from './Heading';
|
||||||
@ -0,0 +1,88 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { IconButton } from './IconButton';
|
||||||
|
import DeleteIcon from '@mui/icons-material/Delete';
|
||||||
|
import SettingsIcon from '@mui/icons-material/Settings';
|
||||||
|
import SearchIcon from '@mui/icons-material/Search';
|
||||||
|
import CloseIcon from '@mui/icons-material/Close';
|
||||||
|
|
||||||
|
const meta: Meta<typeof IconButton> = {
|
||||||
|
title: 'Actions/IconButton',
|
||||||
|
component: IconButton,
|
||||||
|
argTypes: {
|
||||||
|
size: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['small', 'medium'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof IconButton>;
|
||||||
|
|
||||||
|
export const Search: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Поиск',
|
||||||
|
children: <SearchIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Close: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Закрыть',
|
||||||
|
children: <CloseIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Delete: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Удалить',
|
||||||
|
children: <DeleteIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Settings: Story = {
|
||||||
|
args: {
|
||||||
|
label: 'Настройки',
|
||||||
|
children: <SettingsIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Small: Story = {
|
||||||
|
args: {
|
||||||
|
size: 'small',
|
||||||
|
label: 'Поиск',
|
||||||
|
children: <SearchIcon />,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllSizes: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||||
|
<IconButton size="small" label="Поиск (маленькая)">
|
||||||
|
<SearchIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton size="medium" label="Поиск (средняя)">
|
||||||
|
<SearchIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllExamples: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', gap: 12 }}>
|
||||||
|
<IconButton label="Поиск">
|
||||||
|
<SearchIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton label="Настройки">
|
||||||
|
<SettingsIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton label="Удалить">
|
||||||
|
<DeleteIcon />
|
||||||
|
</IconButton>
|
||||||
|
<IconButton label="Закрыть">
|
||||||
|
<CloseIcon />
|
||||||
|
</IconButton>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
@ -0,0 +1,68 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { IconButton } from './IconButton';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('IconButton', () => {
|
||||||
|
it('renders with aria-label', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<IconButton label="Close">
|
||||||
|
<span>X</span>
|
||||||
|
</IconButton>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders children', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<IconButton label="Menu">
|
||||||
|
<span>☰</span>
|
||||||
|
</IconButton>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('☰')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pass color prop to DOM', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<IconButton label="Search">
|
||||||
|
<span>🔍</span>
|
||||||
|
</IconButton>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn).not.toHaveAttribute('color');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts className and style props', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<IconButton label="Settings" className="custom" style={{ margin: 4 }}>
|
||||||
|
<span>⚙</span>
|
||||||
|
</IconButton>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.classList.contains('custom')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders small size', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<IconButton label="Small" size="small">
|
||||||
|
<span>S</span>
|
||||||
|
</IconButton>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.classList.contains('MuiIconButton-sizeSmall')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders medium size by default', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<IconButton label="Medium">
|
||||||
|
<span>M</span>
|
||||||
|
</IconButton>,
|
||||||
|
);
|
||||||
|
const btn = screen.getByRole('button');
|
||||||
|
expect(btn.classList.contains('MuiIconButton-sizeMedium')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
IconButton as MuiIconButton,
|
||||||
|
type IconButtonProps as MuiIconButtonProps,
|
||||||
|
} from '@mui/material';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface IconButtonProps extends Omit<
|
||||||
|
MuiIconButtonProps,
|
||||||
|
'color' | 'variant' | 'sx' | 'aria-label'
|
||||||
|
> {
|
||||||
|
label: string;
|
||||||
|
size?: 'small' | 'medium';
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function IconButton({ label, size = 'medium', children, ...props }: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<MuiIconButton aria-label={label} size={size} {...props}>
|
||||||
|
{children}
|
||||||
|
</MuiIconButton>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
export { IconButton } from './IconButton';
|
||||||
|
export type { IconButtonProps } from './IconButton';
|
||||||
70
packages/design-system/src/components/Link/Link.stories.tsx
Normal file
70
packages/design-system/src/components/Link/Link.stories.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Link } from './Link';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Link> = {
|
||||||
|
title: 'Navigation/Link',
|
||||||
|
component: Link,
|
||||||
|
argTypes: {
|
||||||
|
tone: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['default', 'secondary', 'positive', 'negative', 'muted'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Link>;
|
||||||
|
|
||||||
|
export const Default: Story = {
|
||||||
|
args: {
|
||||||
|
href: '#',
|
||||||
|
children: 'Перейти к котировкам',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const External: Story = {
|
||||||
|
args: {
|
||||||
|
href: 'https://www.moex.com',
|
||||||
|
target: '_blank',
|
||||||
|
rel: 'noopener noreferrer',
|
||||||
|
children: 'Московская Биржа',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Positive: Story = {
|
||||||
|
args: {
|
||||||
|
href: '#',
|
||||||
|
tone: 'positive',
|
||||||
|
children: 'Рост акций',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Negative: Story = {
|
||||||
|
args: {
|
||||||
|
href: '#',
|
||||||
|
tone: 'negative',
|
||||||
|
children: 'Падение индекса',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllTones: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
||||||
|
<Link href="#" tone="default">
|
||||||
|
Основная ссылка
|
||||||
|
</Link>
|
||||||
|
<Link href="#" tone="secondary">
|
||||||
|
Второстепенная ссылка
|
||||||
|
</Link>
|
||||||
|
<Link href="#" tone="positive">
|
||||||
|
Рост +5.2%
|
||||||
|
</Link>
|
||||||
|
<Link href="#" tone="negative">
|
||||||
|
Падение -2.1%
|
||||||
|
</Link>
|
||||||
|
<Link href="#" tone="muted">
|
||||||
|
Неактивная ссылка
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
53
packages/design-system/src/components/Link/Link.test.tsx
Normal file
53
packages/design-system/src/components/Link/Link.test.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Link } from './Link';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Link', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
renderWithTheme(<Link href="/test">Click</Link>);
|
||||||
|
expect(screen.getByText('Click')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders as an anchor with href', () => {
|
||||||
|
renderWithTheme(<Link href="/test">Click</Link>);
|
||||||
|
const el = screen.getByText('Click');
|
||||||
|
expect(el.tagName).toBe('A');
|
||||||
|
expect(el).toHaveAttribute('href', '/test');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not pass color prop to DOM', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Link href="/test" tone="positive">
|
||||||
|
Click
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
const el = screen.getByText('Click');
|
||||||
|
expect(el).not.toHaveAttribute('color');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts className and style props', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Link href="/test" className="custom" style={{ margin: 4 }}>
|
||||||
|
Click
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
const el = screen.getByText('Click');
|
||||||
|
expect(el.classList.contains('custom')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with target and rel for external links', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Link href="https://example.com" target="_blank" rel="noopener">
|
||||||
|
External
|
||||||
|
</Link>,
|
||||||
|
);
|
||||||
|
const el = screen.getByText('External');
|
||||||
|
expect(el).toHaveAttribute('target', '_blank');
|
||||||
|
expect(el).toHaveAttribute('rel', 'noopener');
|
||||||
|
});
|
||||||
|
});
|
||||||
25
packages/design-system/src/components/Link/Link.tsx
Normal file
25
packages/design-system/src/components/Link/Link.tsx
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import { Link as MuiLink, type LinkProps as MuiLinkProps } from '@mui/material';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Tone = 'default' | 'secondary' | 'positive' | 'negative' | 'muted';
|
||||||
|
|
||||||
|
export interface LinkProps extends Omit<MuiLinkProps, 'variant' | 'color' | 'sx'> {
|
||||||
|
tone?: Tone;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TONE_MAP: Record<Tone, MuiLinkProps['color']> = {
|
||||||
|
default: 'primary',
|
||||||
|
secondary: 'text.secondary',
|
||||||
|
positive: 'success.main',
|
||||||
|
negative: 'error.main',
|
||||||
|
muted: 'text.disabled',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Link({ tone = 'default', children, ...props }: LinkProps) {
|
||||||
|
return (
|
||||||
|
<MuiLink color={TONE_MAP[tone]} {...props}>
|
||||||
|
{children}
|
||||||
|
</MuiLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Link/index.ts
Normal file
2
packages/design-system/src/components/Link/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Link } from './Link';
|
||||||
|
export type { LinkProps } from './Link';
|
||||||
99
packages/design-system/src/components/Text/Text.stories.tsx
Normal file
99
packages/design-system/src/components/Text/Text.stories.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import type { Meta, StoryObj } from '@storybook/react';
|
||||||
|
import { Text } from './Text';
|
||||||
|
|
||||||
|
const meta: Meta<typeof Text> = {
|
||||||
|
title: 'Typography/Text',
|
||||||
|
component: Text,
|
||||||
|
argTypes: {
|
||||||
|
variant: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['body', 'caption', 'label', 'numeric'],
|
||||||
|
},
|
||||||
|
tone: {
|
||||||
|
control: 'select',
|
||||||
|
options: ['default', 'secondary', 'positive', 'negative', 'muted'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default meta;
|
||||||
|
type Story = StoryObj<typeof Text>;
|
||||||
|
|
||||||
|
export const Body: Story = {
|
||||||
|
args: {
|
||||||
|
children:
|
||||||
|
'Акции Московской биржи выросли на 2.3% по итогам торговой сессии. Индекс Мосбиржи обновил исторический максимум, достигнув отметки 4500 пунктов.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Caption: Story = {
|
||||||
|
args: {
|
||||||
|
variant: 'caption',
|
||||||
|
children: 'Данные предоставлены Московской биржей. Цены указаны в рублях.',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Label: Story = {
|
||||||
|
args: {
|
||||||
|
variant: 'label',
|
||||||
|
children: 'Объём торгов',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Numeric: Story = {
|
||||||
|
args: {
|
||||||
|
variant: 'numeric',
|
||||||
|
children: '1 234 567.89 ₽',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PositiveTone: Story = {
|
||||||
|
args: {
|
||||||
|
tone: 'positive',
|
||||||
|
children: '+5.23%',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const NegativeTone: Story = {
|
||||||
|
args: {
|
||||||
|
tone: 'negative',
|
||||||
|
children: '-2.15%',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SecondaryTone: Story = {
|
||||||
|
args: {
|
||||||
|
tone: 'secondary',
|
||||||
|
children: 'Дополнительная информация о ценной бумаге',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const MutedTone: Story = {
|
||||||
|
args: {
|
||||||
|
tone: 'muted',
|
||||||
|
children: 'Обновлено 5 минут назад',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllVariants: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<Text variant="body">Body: основной текст страницы</Text>
|
||||||
|
<Text variant="caption">Caption: вспомогательный текст</Text>
|
||||||
|
<Text variant="label">Label: подпись к полю или метрике</Text>
|
||||||
|
<Text variant="numeric">Numeric: 1 234.56</Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AllTones: Story = {
|
||||||
|
render: () => (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
||||||
|
<Text tone="default">Default — основной текст</Text>
|
||||||
|
<Text tone="secondary">Secondary — второстепенный текст</Text>
|
||||||
|
<Text tone="positive">Positive — рост, прибыль</Text>
|
||||||
|
<Text tone="negative">Negative — падение, убыток</Text>
|
||||||
|
<Text tone="muted">Muted — неактивный, подсказка</Text>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
53
packages/design-system/src/components/Text/Text.test.tsx
Normal file
53
packages/design-system/src/components/Text/Text.test.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Text } from './Text';
|
||||||
|
import { MoexVibeThemeProvider } from '../../theme';
|
||||||
|
|
||||||
|
function renderWithTheme(element: React.ReactElement) {
|
||||||
|
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Text', () => {
|
||||||
|
it('renders children', () => {
|
||||||
|
renderWithTheme(<Text>Hello</Text>);
|
||||||
|
expect(screen.getByText('Hello')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders with body variant by default', () => {
|
||||||
|
renderWithTheme(<Text>Body</Text>);
|
||||||
|
const el = screen.getByText('Body');
|
||||||
|
expect(el.tagName).toBe('P');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders caption variant as MUI caption', () => {
|
||||||
|
renderWithTheme(<Text variant="caption">Caption</Text>);
|
||||||
|
const el = screen.getByText('Caption');
|
||||||
|
expect(el.classList.contains('MuiTypography-caption')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders label variant', () => {
|
||||||
|
renderWithTheme(<Text variant="label">Label</Text>);
|
||||||
|
expect(screen.getByText('Label')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders numeric variant', () => {
|
||||||
|
renderWithTheme(<Text variant="numeric">123</Text>);
|
||||||
|
expect(screen.getByText('123')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies default tone by default', () => {
|
||||||
|
renderWithTheme(<Text>Default</Text>);
|
||||||
|
const el = screen.getByText('Default');
|
||||||
|
expect(el.classList.contains('MuiTypography-body1')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts className and style props', () => {
|
||||||
|
renderWithTheme(
|
||||||
|
<Text className="custom" style={{ margin: 4 }}>
|
||||||
|
Hello
|
||||||
|
</Text>,
|
||||||
|
);
|
||||||
|
const el = screen.getByText('Hello');
|
||||||
|
expect(el.classList.contains('custom')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
35
packages/design-system/src/components/Text/Text.tsx
Normal file
35
packages/design-system/src/components/Text/Text.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { Typography, type TypographyProps } from '@mui/material';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
type Tone = 'default' | 'secondary' | 'positive' | 'negative' | 'muted';
|
||||||
|
|
||||||
|
type TextVariant = 'body' | 'caption' | 'label' | 'numeric';
|
||||||
|
|
||||||
|
export interface TextProps extends Omit<TypographyProps, 'variant' | 'color' | 'sx'> {
|
||||||
|
variant?: TextVariant;
|
||||||
|
tone?: Tone;
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VARIANT_MAP: Record<TextVariant, TypographyProps['variant']> = {
|
||||||
|
body: 'body1',
|
||||||
|
caption: 'caption',
|
||||||
|
label: 'subtitle2',
|
||||||
|
numeric: 'body1',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TONE_MAP: Record<Tone, TypographyProps['color']> = {
|
||||||
|
default: 'text.primary',
|
||||||
|
secondary: 'text.secondary',
|
||||||
|
positive: 'success.main',
|
||||||
|
negative: 'error.main',
|
||||||
|
muted: 'text.disabled',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Text({ variant = 'body', tone = 'default', children, ...props }: TextProps) {
|
||||||
|
return (
|
||||||
|
<Typography variant={VARIANT_MAP[variant]} color={TONE_MAP[tone]} {...props}>
|
||||||
|
{children}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
2
packages/design-system/src/components/Text/index.ts
Normal file
2
packages/design-system/src/components/Text/index.ts
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
export { Text } from './Text';
|
||||||
|
export type { TextProps } from './Text';
|
||||||
14
packages/design-system/src/components/index.ts
Normal file
14
packages/design-system/src/components/index.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
export { Text } from './Text';
|
||||||
|
export type { TextProps } from './Text';
|
||||||
|
|
||||||
|
export { Heading } from './Heading';
|
||||||
|
export type { HeadingProps } from './Heading';
|
||||||
|
|
||||||
|
export { Link } from './Link';
|
||||||
|
export type { LinkProps } from './Link';
|
||||||
|
|
||||||
|
export { Button } from './Button';
|
||||||
|
export type { ButtonProps } from './Button';
|
||||||
|
|
||||||
|
export { IconButton } from './IconButton';
|
||||||
|
export type { IconButtonProps } from './IconButton';
|
||||||
1
packages/design-system/src/index.ts
Normal file
1
packages/design-system/src/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export * from './components';
|
||||||
Loading…
x
Reference in New Issue
Block a user