feat(design-system): add typography and actions
Implement Text, Heading, Link, Button, and IconButton components with TDD and Storybook stories. Each component wraps MUI with restricted props for visual consistency.
This commit is contained in:
parent
886ac7b87a
commit
74ca576310
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