From 55221dbf2235bcae64304eb1eaaaba3486fc91fa Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Sun, 21 Jun 2026 09:44:12 +0300 Subject: [PATCH] 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 --- .../EmptyState/EmptyState.stories.tsx | 36 +++++++++++ .../components/EmptyState/EmptyState.test.tsx | 26 ++++++++ .../src/components/EmptyState/EmptyState.tsx | 20 ++++++ .../src/components/EmptyState/index.ts | 2 + .../ErrorState/ErrorState.stories.tsx | 36 +++++++++++ .../components/ErrorState/ErrorState.test.tsx | 26 ++++++++ .../src/components/ErrorState/ErrorState.tsx | 20 ++++++ .../src/components/ErrorState/index.ts | 2 + .../FilterBar/FilterBar.stories.tsx | 38 +++++++++++ .../components/FilterBar/FilterBar.test.tsx | 28 ++++++++ .../src/components/FilterBar/FilterBar.tsx | 22 +++++++ .../src/components/FilterBar/index.ts | 2 + .../FormField/FormField.stories.tsx | 64 +++++++++++++++++++ .../components/FormField/FormField.test.tsx | 48 ++++++++++++++ .../src/components/FormField/FormField.tsx | 28 ++++++++ .../src/components/FormField/index.ts | 2 + .../LoadingState/LoadingState.stories.tsx | 41 ++++++++++++ .../LoadingState/LoadingState.test.tsx | 38 +++++++++++ .../components/LoadingState/LoadingState.tsx | 30 +++++++++ .../src/components/LoadingState/index.ts | 2 + .../design-system/src/components/index.ts | 60 +++++++++++++++++ 21 files changed, 571 insertions(+) create mode 100644 packages/design-system/src/components/EmptyState/EmptyState.stories.tsx create mode 100644 packages/design-system/src/components/EmptyState/EmptyState.test.tsx create mode 100644 packages/design-system/src/components/EmptyState/EmptyState.tsx create mode 100644 packages/design-system/src/components/EmptyState/index.ts create mode 100644 packages/design-system/src/components/ErrorState/ErrorState.stories.tsx create mode 100644 packages/design-system/src/components/ErrorState/ErrorState.test.tsx create mode 100644 packages/design-system/src/components/ErrorState/ErrorState.tsx create mode 100644 packages/design-system/src/components/ErrorState/index.ts create mode 100644 packages/design-system/src/components/FilterBar/FilterBar.stories.tsx create mode 100644 packages/design-system/src/components/FilterBar/FilterBar.test.tsx create mode 100644 packages/design-system/src/components/FilterBar/FilterBar.tsx create mode 100644 packages/design-system/src/components/FilterBar/index.ts create mode 100644 packages/design-system/src/components/FormField/FormField.stories.tsx create mode 100644 packages/design-system/src/components/FormField/FormField.test.tsx create mode 100644 packages/design-system/src/components/FormField/FormField.tsx create mode 100644 packages/design-system/src/components/FormField/index.ts create mode 100644 packages/design-system/src/components/LoadingState/LoadingState.stories.tsx create mode 100644 packages/design-system/src/components/LoadingState/LoadingState.test.tsx create mode 100644 packages/design-system/src/components/LoadingState/LoadingState.tsx create mode 100644 packages/design-system/src/components/LoadingState/index.ts diff --git a/packages/design-system/src/components/EmptyState/EmptyState.stories.tsx b/packages/design-system/src/components/EmptyState/EmptyState.stories.tsx new file mode 100644 index 0000000..5331a49 --- /dev/null +++ b/packages/design-system/src/components/EmptyState/EmptyState.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Button } from '../Button'; +import { EmptyState } from './EmptyState'; + +const meta: Meta = { + title: 'Feedback/EmptyState', + component: EmptyState, + argTypes: { + title: { control: 'text' }, + description: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +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: , + }, +}; + +export const Minimal: Story = { + args: { + title: 'Nothing here yet', + }, +}; diff --git a/packages/design-system/src/components/EmptyState/EmptyState.test.tsx b/packages/design-system/src/components/EmptyState/EmptyState.test.tsx new file mode 100644 index 0000000..ab70e44 --- /dev/null +++ b/packages/design-system/src/components/EmptyState/EmptyState.test.tsx @@ -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({element}); +} + +describe('EmptyState', () => { + it('renders title as semantic heading', () => { + renderWithTheme(); + const heading = screen.getByText('No data'); + expect(heading.tagName).toBe('H2'); + }); + + it('renders description', () => { + renderWithTheme(); + expect(screen.getByText('Try adjusting filters')).toBeInTheDocument(); + }); + + it('renders action', () => { + renderWithTheme(Refresh} />); + expect(screen.getByRole('button', { name: 'Refresh' })).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/EmptyState/EmptyState.tsx b/packages/design-system/src/components/EmptyState/EmptyState.tsx new file mode 100644 index 0000000..80eb7f2 --- /dev/null +++ b/packages/design-system/src/components/EmptyState/EmptyState.tsx @@ -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 ( + + {title} + {description && {description}} + {action &&
{action}
} +
+ ); +} diff --git a/packages/design-system/src/components/EmptyState/index.ts b/packages/design-system/src/components/EmptyState/index.ts new file mode 100644 index 0000000..0d24236 --- /dev/null +++ b/packages/design-system/src/components/EmptyState/index.ts @@ -0,0 +1,2 @@ +export { EmptyState } from './EmptyState'; +export type { EmptyStateProps } from './EmptyState'; diff --git a/packages/design-system/src/components/ErrorState/ErrorState.stories.tsx b/packages/design-system/src/components/ErrorState/ErrorState.stories.tsx new file mode 100644 index 0000000..f7efff5 --- /dev/null +++ b/packages/design-system/src/components/ErrorState/ErrorState.stories.tsx @@ -0,0 +1,36 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Button } from '../Button'; +import { ErrorState } from './ErrorState'; + +const meta: Meta = { + title: 'Feedback/ErrorState', + component: ErrorState, + argTypes: { + title: { control: 'text' }, + description: { control: 'text' }, + }, +}; + +export default meta; +type Story = StoryObj; + +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: , + }, +}; + +export const Minimal: Story = { + args: { + title: 'Error loading data', + }, +}; diff --git a/packages/design-system/src/components/ErrorState/ErrorState.test.tsx b/packages/design-system/src/components/ErrorState/ErrorState.test.tsx new file mode 100644 index 0000000..699f700 --- /dev/null +++ b/packages/design-system/src/components/ErrorState/ErrorState.test.tsx @@ -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({element}); +} + +describe('ErrorState', () => { + it('renders title as semantic heading', () => { + renderWithTheme(); + const heading = screen.getByText('Error loading'); + expect(heading.tagName).toBe('H2'); + }); + + it('renders description', () => { + renderWithTheme(); + expect(screen.getByText('Something went wrong')).toBeInTheDocument(); + }); + + it('renders action', () => { + renderWithTheme(Retry} />); + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/ErrorState/ErrorState.tsx b/packages/design-system/src/components/ErrorState/ErrorState.tsx new file mode 100644 index 0000000..f4fd49f --- /dev/null +++ b/packages/design-system/src/components/ErrorState/ErrorState.tsx @@ -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 ( + + {title} + {description && {description}} + {action &&
{action}
} +
+ ); +} diff --git a/packages/design-system/src/components/ErrorState/index.ts b/packages/design-system/src/components/ErrorState/index.ts new file mode 100644 index 0000000..345d022 --- /dev/null +++ b/packages/design-system/src/components/ErrorState/index.ts @@ -0,0 +1,2 @@ +export { ErrorState } from './ErrorState'; +export type { ErrorStateProps } from './ErrorState'; diff --git a/packages/design-system/src/components/FilterBar/FilterBar.stories.tsx b/packages/design-system/src/components/FilterBar/FilterBar.stories.tsx new file mode 100644 index 0000000..8a81bfb --- /dev/null +++ b/packages/design-system/src/components/FilterBar/FilterBar.stories.tsx @@ -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 = { + title: 'Layout/FilterBar', + component: FilterBar, +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + args: { + children: ( + <> + + + , + ); + expect(screen.getByPlaceholderText('Search')).toBeInTheDocument(); + }); + + it('renders actions', () => { + renderWithTheme( + Apply}> + + , + ); + expect(screen.getByRole('button', { name: 'Apply' })).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/FilterBar/FilterBar.tsx b/packages/design-system/src/components/FilterBar/FilterBar.tsx new file mode 100644 index 0000000..c1ba772 --- /dev/null +++ b/packages/design-system/src/components/FilterBar/FilterBar.tsx @@ -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 ( + + + {children} + + {actions && ( + + {actions} + + )} + + ); +} diff --git a/packages/design-system/src/components/FilterBar/index.ts b/packages/design-system/src/components/FilterBar/index.ts new file mode 100644 index 0000000..38debcb --- /dev/null +++ b/packages/design-system/src/components/FilterBar/index.ts @@ -0,0 +1,2 @@ +export { FilterBar } from './FilterBar'; +export type { FilterBarProps } from './FilterBar'; diff --git a/packages/design-system/src/components/FormField/FormField.stories.tsx b/packages/design-system/src/components/FormField/FormField.stories.tsx new file mode 100644 index 0000000..257d88e --- /dev/null +++ b/packages/design-system/src/components/FormField/FormField.stories.tsx @@ -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 = { + title: 'Form/FormField', + component: FormField, + argTypes: { + label: { control: 'text' }, + helperText: { control: 'text' }, + error: { control: 'text' }, + required: { control: 'boolean' }, + }, +}; + +export default meta; +type Story = StoryObj; + +export const WithTextField: Story = { + args: { + label: 'Username', + htmlFor: 'username', + helperText: 'Enter your username', + children: , + }, +}; + +export const Required: Story = { + args: { + label: 'Email', + htmlFor: 'email', + required: true, + helperText: 'We will never share your email', + children: , + }, +}; + +export const WithError: Story = { + args: { + label: 'Name', + htmlFor: 'name', + error: 'Name is required', + children: , + }, +}; + +export const WithSelect: Story = { + args: { + label: 'Country', + htmlFor: 'country', + children: ( + + , + ); + const label = screen.getByText('Username'); + expect(label.tagName).toBe('LABEL'); + expect(label).toHaveAttribute('for', 'username'); + }); + + it('renders helper text', () => { + renderWithTheme( + + + , + ); + expect(screen.getByText('Enter your email')).toBeInTheDocument(); + }); + + it('renders error text and associates with input', () => { + renderWithTheme( + + + , + ); + expect(screen.getByText('Name is required')).toBeInTheDocument(); + }); + + it('renders required indicator', () => { + renderWithTheme( + + + , + ); + expect(screen.getByText('*')).toBeInTheDocument(); + }); +}); diff --git a/packages/design-system/src/components/FormField/FormField.tsx b/packages/design-system/src/components/FormField/FormField.tsx new file mode 100644 index 0000000..a329bfc --- /dev/null +++ b/packages/design-system/src/components/FormField/FormField.tsx @@ -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 ( + + {label} + {children} + {(helperText || error) && {error || helperText}} + + ); +} diff --git a/packages/design-system/src/components/FormField/index.ts b/packages/design-system/src/components/FormField/index.ts new file mode 100644 index 0000000..269f0c7 --- /dev/null +++ b/packages/design-system/src/components/FormField/index.ts @@ -0,0 +1,2 @@ +export { FormField } from './FormField'; +export type { FormFieldProps } from './FormField'; diff --git a/packages/design-system/src/components/LoadingState/LoadingState.stories.tsx b/packages/design-system/src/components/LoadingState/LoadingState.stories.tsx new file mode 100644 index 0000000..49a3925 --- /dev/null +++ b/packages/design-system/src/components/LoadingState/LoadingState.stories.tsx @@ -0,0 +1,41 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { LoadingState } from './LoadingState'; + +const meta: Meta = { + title: 'Feedback/LoadingState', + component: LoadingState, + argTypes: { + title: { control: 'text' }, + label: { control: 'text' }, + size: { + control: 'select', + options: ['section', 'page'], + }, + }, +}; + +export default meta; +type Story = StoryObj; + +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', + }, +}; diff --git a/packages/design-system/src/components/LoadingState/LoadingState.test.tsx b/packages/design-system/src/components/LoadingState/LoadingState.test.tsx new file mode 100644 index 0000000..1b0652b --- /dev/null +++ b/packages/design-system/src/components/LoadingState/LoadingState.test.tsx @@ -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({element}); +} + +describe('LoadingState', () => { + it('has aria-live polite', () => { + renderWithTheme(); + const region = screen.getByRole('region'); + expect(region).toHaveAttribute('aria-live', 'polite'); + }); + + it('renders title', () => { + renderWithTheme(); + expect(screen.getByText('Loading data')).toBeInTheDocument(); + }); + + it('renders label', () => { + renderWithTheme(); + expect(screen.getByText('Fetching portfolio')).toBeInTheDocument(); + }); + + it('renders with section size by default', () => { + renderWithTheme(); + const region = screen.getByRole('region'); + expect(region).toHaveAttribute('data-size', 'section'); + }); + + it('renders with page size', () => { + renderWithTheme(); + const region = screen.getByRole('region'); + expect(region).toHaveAttribute('data-size', 'page'); + }); +}); diff --git a/packages/design-system/src/components/LoadingState/LoadingState.tsx b/packages/design-system/src/components/LoadingState/LoadingState.tsx new file mode 100644 index 0000000..e8685bd --- /dev/null +++ b/packages/design-system/src/components/LoadingState/LoadingState.tsx @@ -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 ( + + + + {title} + {label && {label}} + + + ); +} diff --git a/packages/design-system/src/components/LoadingState/index.ts b/packages/design-system/src/components/LoadingState/index.ts new file mode 100644 index 0000000..59024cd --- /dev/null +++ b/packages/design-system/src/components/LoadingState/index.ts @@ -0,0 +1,2 @@ +export { LoadingState } from './LoadingState'; +export type { LoadingStateProps } from './LoadingState'; diff --git a/packages/design-system/src/components/index.ts b/packages/design-system/src/components/index.ts index 7f583ee..9f98132 100644 --- a/packages/design-system/src/components/index.ts +++ b/packages/design-system/src/components/index.ts @@ -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';