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:
Sergey Krylov 2026-06-21 09:32:58 +03:00
parent 886ac7b87a
commit 74ca576310
22 changed files with 968 additions and 0 deletions

View 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>
),
};

View 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);
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Button } from './Button';
export type { ButtonProps } from './Button';

View File

@ -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>
),
};

View File

@ -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);
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Heading } from './Heading';
export type { HeadingProps } from './Heading';

View File

@ -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>
),
};

View File

@ -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);
});
});

View File

@ -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>
);
}

View File

@ -0,0 +1,2 @@
export { IconButton } from './IconButton';
export type { IconButtonProps } from './IconButton';

View 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>
),
};

View 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');
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Link } from './Link';
export type { LinkProps } from './Link';

View 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>
),
};

View 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);
});
});

View 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>
);
}

View File

@ -0,0 +1,2 @@
export { Text } from './Text';
export type { TextProps } from './Text';

View 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';

View File

@ -0,0 +1 @@
export * from './components';