codex/design-system-foundation #33

Merged
ksv741 merged 12 commits from codex/design-system-foundation into main 2026-06-21 16:44:32 +03:00
22 changed files with 968 additions and 0 deletions
Showing only changes of commit 74ca576310 - Show all commits

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