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
21 changed files with 571 additions and 0 deletions
Showing only changes of commit 55221dbf22 - Show all commits

View File

@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from '../Button';
import { EmptyState } from './EmptyState';
const meta: Meta<typeof EmptyState> = {
title: 'Feedback/EmptyState',
component: EmptyState,
argTypes: {
title: { control: 'text' },
description: { control: 'text' },
},
};
export default meta;
type Story = StoryObj<typeof EmptyState>;
export const Basic: Story = {
args: {
title: 'No data available',
description: 'Try adjusting your filters or date range.',
},
};
export const WithAction: Story = {
args: {
title: 'No securities found',
description: 'Try a different search query.',
action: <Button variant="outlined">Clear filters</Button>,
},
};
export const Minimal: Story = {
args: {
title: 'Nothing here yet',
},
};

View File

@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { EmptyState } from './EmptyState';
import { MoexVibeThemeProvider } from '../../theme';
function renderWithTheme(element: React.ReactElement) {
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
}
describe('EmptyState', () => {
it('renders title as semantic heading', () => {
renderWithTheme(<EmptyState title="No data" />);
const heading = screen.getByText('No data');
expect(heading.tagName).toBe('H2');
});
it('renders description', () => {
renderWithTheme(<EmptyState title="No data" description="Try adjusting filters" />);
expect(screen.getByText('Try adjusting filters')).toBeInTheDocument();
});
it('renders action', () => {
renderWithTheme(<EmptyState title="No data" action={<button>Refresh</button>} />);
expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import { Stack } from '@mui/material';
import { Heading } from '../Heading';
import { Text } from '../Text';
export interface EmptyStateProps {
title: string;
description?: string;
action?: ReactNode;
}
export function EmptyState({ title, description, action }: EmptyStateProps) {
return (
<Stack spacing={1} alignItems="center" data-testid="empty-state">
<Heading as="h2">{title}</Heading>
{description && <Text>{description}</Text>}
{action && <div>{action}</div>}
</Stack>
);
}

View File

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

View File

@ -0,0 +1,36 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from '../Button';
import { ErrorState } from './ErrorState';
const meta: Meta<typeof ErrorState> = {
title: 'Feedback/ErrorState',
component: ErrorState,
argTypes: {
title: { control: 'text' },
description: { control: 'text' },
},
};
export default meta;
type Story = StoryObj<typeof ErrorState>;
export const Basic: Story = {
args: {
title: 'Failed to load',
description: 'An unexpected error occurred. Please try again.',
},
};
export const WithRetry: Story = {
args: {
title: 'Connection lost',
description: 'Could not reach the server.',
action: <Button>Retry</Button>,
},
};
export const Minimal: Story = {
args: {
title: 'Error loading data',
},
};

View File

@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ErrorState } from './ErrorState';
import { MoexVibeThemeProvider } from '../../theme';
function renderWithTheme(element: React.ReactElement) {
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
}
describe('ErrorState', () => {
it('renders title as semantic heading', () => {
renderWithTheme(<ErrorState title="Error loading" />);
const heading = screen.getByText('Error loading');
expect(heading.tagName).toBe('H2');
});
it('renders description', () => {
renderWithTheme(<ErrorState title="Error" description="Something went wrong" />);
expect(screen.getByText('Something went wrong')).toBeInTheDocument();
});
it('renders action', () => {
renderWithTheme(<ErrorState title="Error" action={<button>Retry</button>} />);
expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,20 @@
import type { ReactNode } from 'react';
import { Stack } from '@mui/material';
import { Heading } from '../Heading';
import { Text } from '../Text';
export interface ErrorStateProps {
title: string;
description?: string;
action?: ReactNode;
}
export function ErrorState({ title, description, action }: ErrorStateProps) {
return (
<Stack spacing={1} alignItems="center" data-testid="error-state">
<Heading as="h2">{title}</Heading>
{description && <Text>{description}</Text>}
{action && <div>{action}</div>}
</Stack>
);
}

View File

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

View File

@ -0,0 +1,38 @@
import type { Meta, StoryObj } from '@storybook/react';
import { Button } from '../Button';
import { TextField } from '../TextField';
import { Select } from '../Select';
import { FilterBar } from './FilterBar';
const meta: Meta<typeof FilterBar> = {
title: 'Layout/FilterBar',
component: FilterBar,
};
export default meta;
type Story = StoryObj<typeof FilterBar>;
export const Basic: Story = {
args: {
children: (
<>
<TextField placeholder="Search" />
<Select
options={[
{ value: 'all', label: 'All' },
{ value: 'active', label: 'Active' },
]}
value="all"
onChange={() => {}}
/>
</>
),
actions: <Button>Apply</Button>,
},
};
export const NoActions: Story = {
args: {
children: <TextField placeholder="Search securities..." />,
},
};

View File

@ -0,0 +1,28 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { FilterBar } from './FilterBar';
import { MoexVibeThemeProvider } from '../../theme';
function renderWithTheme(element: React.ReactElement) {
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
}
describe('FilterBar', () => {
it('renders children', () => {
renderWithTheme(
<FilterBar>
<input placeholder="Search" />
</FilterBar>,
);
expect(screen.getByPlaceholderText('Search')).toBeInTheDocument();
});
it('renders actions', () => {
renderWithTheme(
<FilterBar actions={<button>Apply</button>}>
<input placeholder="Filter" />
</FilterBar>,
);
expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument();
});
});

View File

@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
import { Stack } from '@mui/material';
export interface FilterBarProps {
children: ReactNode;
actions?: ReactNode;
}
export function FilterBar({ children, actions }: FilterBarProps) {
return (
<Stack direction="row" spacing={2} alignItems="center" flexWrap="wrap" data-testid="filter-bar">
<Stack direction="row" spacing={1} flexWrap="wrap" flex={1}>
{children}
</Stack>
{actions && (
<Stack direction="row" spacing={1}>
{actions}
</Stack>
)}
</Stack>
);
}

View File

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

View File

@ -0,0 +1,64 @@
import type { Meta, StoryObj } from '@storybook/react';
import { TextField } from '../TextField';
import { Select } from '../Select';
import { FormField } from './FormField';
const meta: Meta<typeof FormField> = {
title: 'Form/FormField',
component: FormField,
argTypes: {
label: { control: 'text' },
helperText: { control: 'text' },
error: { control: 'text' },
required: { control: 'boolean' },
},
};
export default meta;
type Story = StoryObj<typeof FormField>;
export const WithTextField: Story = {
args: {
label: 'Username',
htmlFor: 'username',
helperText: 'Enter your username',
children: <TextField id="username" placeholder="username" />,
},
};
export const Required: Story = {
args: {
label: 'Email',
htmlFor: 'email',
required: true,
helperText: 'We will never share your email',
children: <TextField id="email" type="email" />,
},
};
export const WithError: Story = {
args: {
label: 'Name',
htmlFor: 'name',
error: 'Name is required',
children: <TextField id="name" value="" />,
},
};
export const WithSelect: Story = {
args: {
label: 'Country',
htmlFor: 'country',
children: (
<Select
id="country"
options={[
{ value: 'ru', label: 'Russia' },
{ value: 'us', label: 'United States' },
]}
value=""
onChange={() => {}}
/>
),
},
};

View File

@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { FormField } from './FormField';
import { MoexVibeThemeProvider } from '../../theme';
function renderWithTheme(element: React.ReactElement) {
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
}
describe('FormField', () => {
it('renders label with htmlFor', () => {
renderWithTheme(
<FormField label="Username" htmlFor="username">
<input id="username" />
</FormField>,
);
const label = screen.getByText('Username');
expect(label.tagName).toBe('LABEL');
expect(label).toHaveAttribute('for', 'username');
});
it('renders helper text', () => {
renderWithTheme(
<FormField label="Email" htmlFor="email" helperText="Enter your email">
<input id="email" />
</FormField>,
);
expect(screen.getByText('Enter your email')).toBeInTheDocument();
});
it('renders error text and associates with input', () => {
renderWithTheme(
<FormField label="Name" htmlFor="name" error="Name is required">
<input id="name" />
</FormField>,
);
expect(screen.getByText('Name is required')).toBeInTheDocument();
});
it('renders required indicator', () => {
renderWithTheme(
<FormField label="Email" htmlFor="email" required>
<input id="email" />
</FormField>,
);
expect(screen.getByText('*')).toBeInTheDocument();
});
});

View File

@ -0,0 +1,28 @@
import type { ReactNode } from 'react';
import { FormControl, FormLabel, FormHelperText, Box } from '@mui/material';
export interface FormFieldProps {
label: string;
htmlFor: string;
helperText?: string;
error?: string;
required?: boolean;
children: ReactNode;
}
export function FormField({
label,
htmlFor,
helperText,
error,
required,
children,
}: FormFieldProps) {
return (
<FormControl error={!!error} required={required} fullWidth>
<FormLabel htmlFor={htmlFor}>{label}</FormLabel>
<Box sx={{ mt: 0.5 }}>{children}</Box>
{(helperText || error) && <FormHelperText>{error || helperText}</FormHelperText>}
</FormControl>
);
}

View File

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

View File

@ -0,0 +1,41 @@
import type { Meta, StoryObj } from '@storybook/react';
import { LoadingState } from './LoadingState';
const meta: Meta<typeof LoadingState> = {
title: 'Feedback/LoadingState',
component: LoadingState,
argTypes: {
title: { control: 'text' },
label: { control: 'text' },
size: {
control: 'select',
options: ['section', 'page'],
},
},
};
export default meta;
type Story = StoryObj<typeof LoadingState>;
export const Section: Story = {
args: {
title: 'Loading data',
size: 'section',
},
};
export const Page: Story = {
args: {
title: 'Loading your portfolio',
label: 'Please wait while we fetch your data',
size: 'page',
},
};
export const WithLabel: Story = {
args: {
title: 'Processing',
label: 'This may take a moment',
size: 'section',
},
};

View File

@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { LoadingState } from './LoadingState';
import { MoexVibeThemeProvider } from '../../theme';
function renderWithTheme(element: React.ReactElement) {
return render(<MoexVibeThemeProvider>{element}</MoexVibeThemeProvider>);
}
describe('LoadingState', () => {
it('has aria-live polite', () => {
renderWithTheme(<LoadingState title="Loading data" />);
const region = screen.getByRole('region');
expect(region).toHaveAttribute('aria-live', 'polite');
});
it('renders title', () => {
renderWithTheme(<LoadingState title="Loading data" />);
expect(screen.getByText('Loading data')).toBeInTheDocument();
});
it('renders label', () => {
renderWithTheme(<LoadingState title="Loading" label="Fetching portfolio" />);
expect(screen.getByText('Fetching portfolio')).toBeInTheDocument();
});
it('renders with section size by default', () => {
renderWithTheme(<LoadingState title="Loading" />);
const region = screen.getByRole('region');
expect(region).toHaveAttribute('data-size', 'section');
});
it('renders with page size', () => {
renderWithTheme(<LoadingState title="Loading" size="page" />);
const region = screen.getByRole('region');
expect(region).toHaveAttribute('data-size', 'page');
});
});

View File

@ -0,0 +1,30 @@
import { Box, Stack, CircularProgress } from '@mui/material';
import { Heading } from '../Heading';
import { Text } from '../Text';
export interface LoadingStateProps {
title: string;
label?: string;
size?: 'section' | 'page';
}
export function LoadingState({ title, label, size = 'section' }: LoadingStateProps) {
const spacing = size === 'page' ? 3 : 2;
const progressSize = size === 'page' ? 48 : 32;
return (
<Box
role="region"
aria-live="polite"
aria-label={title}
data-size={size}
data-testid="loading-state"
>
<Stack spacing={spacing} alignItems="center">
<CircularProgress size={progressSize} />
<Heading as="h2">{title}</Heading>
{label && <Text>{label}</Text>}
</Stack>
</Box>
);
}

View File

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

View File

@ -12,3 +12,63 @@ export type { ButtonProps } from './Button';
export { IconButton } from './IconButton';
export type { IconButtonProps } from './IconButton';
export { TextField } from './TextField';
export type { TextFieldProps } from './TextField';
export { Select } from './Select';
export type { SelectProps, SelectOption } from './Select';
export { Checkbox } from './Checkbox';
export type { CheckboxProps } from './Checkbox';
export { Surface } from './Surface';
export type { SurfaceProps } from './Surface';
export { Card } from './Card';
export type { CardProps } from './Card';
export { Chip } from './Chip';
export type { ChipProps } from './Chip';
export { Badge } from './Badge';
export type { BadgeProps } from './Badge';
export { Alert } from './Alert';
export type { AlertProps } from './Alert';
export { Dialog } from './Dialog';
export type { DialogProps } from './Dialog';
export { Skeleton } from './Skeleton';
export type { SkeletonProps } from './Skeleton';
export { Progress } from './Progress';
export type { ProgressProps } from './Progress';
export { DataTable } from './DataTable';
export type { DataTableProps, Density } from './DataTable';
export { Metric } from './Metric';
export type { MetricProps } from './Metric';
export { Money } from './Money';
export type { MoneyProps } from './Money';
export { PriceChange } from './PriceChange';
export type { PriceChangeProps } from './PriceChange';
export { FormField } from './FormField';
export type { FormFieldProps } from './FormField';
export { FilterBar } from './FilterBar';
export type { FilterBarProps } from './FilterBar';
export { EmptyState } from './EmptyState';
export type { EmptyStateProps } from './EmptyState';
export { ErrorState } from './ErrorState';
export type { ErrorStateProps } from './ErrorState';
export { LoadingState } from './LoadingState';
export type { LoadingStateProps } from './LoadingState';