feat(design-system): add form and page-state patterns
- FormField: label, htmlFor, helperText, error, required, children - FilterBar: responsive layout container with children and actions - EmptyState: semantic heading with optional description and action - ErrorState: semantic heading with optional description and action - LoadingState: aria-live polite, section/page sizes, reduced-motion
This commit is contained in:
parent
74ca576310
commit
55221dbf22
@ -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',
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { EmptyState } from './EmptyState';
|
||||
export type { EmptyStateProps } from './EmptyState';
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { ErrorState } from './ErrorState';
|
||||
export type { ErrorStateProps } from './ErrorState';
|
||||
@ -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..." />,
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/FilterBar/index.ts
Normal file
2
packages/design-system/src/components/FilterBar/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { FilterBar } from './FilterBar';
|
||||
export type { FilterBarProps } from './FilterBar';
|
||||
@ -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={() => {}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
};
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
2
packages/design-system/src/components/FormField/index.ts
Normal file
2
packages/design-system/src/components/FormField/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { FormField } from './FormField';
|
||||
export type { FormFieldProps } from './FormField';
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -0,0 +1,2 @@
|
||||
export { LoadingState } from './LoadingState';
|
||||
export type { LoadingStateProps } from './LoadingState';
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user