Модуль 4. Формы и базовые интерактивные компоненты (ранний Effector)
Цель модуля: реализовать компоненты форм (
Input,Textarea,Select,Checkbox) с валидацией и ARIA, закрепить основы Effector (М3) на изолированных кейсах — стор тостов и стор модалки подключаются к UI.
К концу модуля у вас:
Input(сforwardRef, prefix/suffix, loading),Textarea,Select,Checkbox— все с CSS Modules, тестами и stories.- Стор тостов (
$toasts,toast.success(),toast.error()) на Effector — с unit-тестами. - Виджет
ChatInput(textarea + кнопка отправки), подключающий стор тостов. - Все компоненты форм покрыты axe-аудитом в Storybook.
📌 Перед началом: Модуль 3 (Effector). 🛠 Инструменты: React, forwardRef, ARIA. ⏱ Время: ~5 ч.
🔥 Инцидент: сообщение исчезло навсегда
Пользователь пишет длинное сообщение. Нажимает Send — интернет пропал. Сообщение исчезло навсегда. Нет валидации, нет очереди, нет тоста. Форма должна быть надёжной, даже когда сети нет: ARIA-ошибки, controlled-пропсы, стор тостов и валидация до отправки.
📖 Теория
4.1 Input — с forwardRef и состояниями
forwardRef обязателен: без него не работает react-hook-form и нативный .focus(). React 19 рекомендует ref как пропс напрямую, но мы поддерживаем совместимость через forwardRef.
Важно:
Omit<InputHTMLAttributes, 'prefix'>— убираем конфликт с нативным HTML-атрибутомprefix(типstring), чтобы определить свойprefix: ReactNodeдля иконок/префиксов инпута.
tsx// ui-kit/src/components/Input/Input.tsx import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react' import styles from './Input.module.css' interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> { label?: string hint?: string error?: string prefix?: ReactNode suffix?: ReactNode loading?: boolean } export const Input = forwardRef<HTMLInputElement, InputProps>(function Input( { label, hint, error, prefix, suffix, loading, className, id, ...rest }, ref, ) { const inputId = id ?? rest.name const errorId = error ? `${inputId}-error` : undefined const hintId = hint ? `${inputId}-hint` : undefined return ( <div className={[styles.wrapper, className].filter(Boolean).join(' ')}> {label && ( <label htmlFor={inputId} className={styles.label}> {label} </label> )} <div className={[styles.field, error && styles.errorState, loading && styles.loadingState].filter(Boolean).join(' ')}> {prefix && <span className={styles.prefix}>{prefix}</span>} <input ref={ref} id={inputId} className={styles.input} aria-invalid={error ? true : undefined} aria-describedby={[errorId, hintId].filter(Boolean).join(' ') || undefined} aria-busy={loading || undefined} {...rest} /> {suffix && <span className={styles.suffix}>{suffix}</span>} </div> {error ? ( <span id={errorId} className={styles.errorText} role="alert">{error}</span> ) : hint ? ( <span id={hintId} className={styles.hintText}>{hint}</span> ) : null} </div> ) })
css/* ui-kit/src/components/Input/Input.module.css */ .wrapper { display: flex; flex-direction: column; gap: var(--s-xs); } .label { font-size: var(--font-size-sm); font-weight: 500; color: var(--c-text-muted); } .field { display: flex; align-items: center; background: var(--c-surface); border: 1px solid var(--c-border); border-radius: var(--radius-md); transition: border-color 0.15s; } .field:focus-within { border-color: var(--c-primary); } .input { flex: 1; border: none; background: transparent; outline: none; padding: var(--s-sm) var(--s-md); color: var(--c-text); font-size: var(--font-size-base); font-family: inherit; min-width: 0; } .input::placeholder { color: var(--c-text-faint); } .prefix, .suffix { padding: 0 var(--s-sm); color: var(--c-text-muted); display: flex; align-items: center; } .errorState { border-color: var(--c-danger); } .loadingState { opacity: 0.7; } .errorText { font-size: var(--font-size-sm); color: var(--c-danger); } .hintText { font-size: var(--font-size-sm); color: var(--c-text-muted); }
ARIA-инварианты
| Состояние | Атрибут | Назначение |
|---|---|---|
| Ошибка | aria-invalid="true" | Screen reader объявляет «недействительное» |
| Ошибка | aria-describedby="{id}-error" | Связывает инпут с текстом ошибки |
| Ошибка | role="alert" на тексте ошибки | Screen reader немедленно зачитывает |
| Hint | aria-describedby="{id}-hint" | Связывает с подсказкой |
| Loading | aria-busy="true" | Объявляет «занято, ожидание» |
Тестируйте через keyboard: Tab до инпута → набираете → видите ошибку → Enter → форма сабмитится. Это базовый флоу, который должен работать без мыши.
4.2 Textarea — для сообщений
tsx// ui-kit/src/components/Textarea/Textarea.tsx import { forwardRef, type TextareaHTMLAttributes } from 'react' import styles from './Textarea.module.css' interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> { label?: string error?: string } export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea( { label, error, className, id, ...rest }, ref, ) { return ( <div className={[styles.wrapper, className].filter(Boolean).join(' ')}> {label && <label htmlFor={id} className={styles.label}>{label}</label>} <textarea ref={ref} id={id} className={[styles.textarea, error && styles.errorState].filter(Boolean).join(' ')} aria-invalid={error ? true : undefined} aria-describedby={error ? `${id}-error` : undefined} {...rest} /> {error && <span id={`${id}-error`} className={styles.errorText} role="alert">{error}</span>} </div> ) })
Textarea— база дляMentionInput(М7): textarea + парсинг@→ автокомплит.
4.3 Select, Checkbox, Radio
Select
tsx// ui-kit/src/components/Select/Select.tsx import { forwardRef, type SelectHTMLAttributes } from 'react' import styles from './Select.module.css' interface Option { value: string; label: string } interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> { label?: string error?: string options: Option[] } export const Select = forwardRef<HTMLSelectElement, SelectProps>(function Select( { label, error, options, className, id, ...rest }, ref, ) { return ( <div className={[styles.wrapper, className].filter(Boolean).join(' ')}> {label && <label htmlFor={id} className={styles.label}>{label}</label>} <select ref={ref} id={id} className={[styles.select, error && styles.errorState].filter(Boolean).join(' ')} {...rest}> {options.map((opt) => <option key={opt.value} value={opt.value}>{opt.label}</option>)} </select> {error && <span className={styles.errorText} role="alert">{error}</span>} </div> ) })
Checkbox
tsx// ui-kit/src/components/Checkbox/Checkbox.tsx import { forwardRef, type InputHTMLAttributes } from 'react' import styles from './Checkbox.module.css' interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> { label?: string } export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox( { label, className, id, ...rest }, ref, ) { return ( <label htmlFor={id} className={[styles.wrapper, className].filter(Boolean).join(' ')}> <input ref={ref} id={id} type="checkbox" className={styles.checkbox} {...rest} /> {label && <span className={styles.label}>{label}</span>} </label> ) })
4.4 Стор тостов на Effector (ранний кейс)
Теперь — соединяем теорию М3 с UI. Создаём стор тостов в app/features/toasts/:
ts// app/features/toasts/model.ts import { createEvent, createStore, sample } from 'effector' export type ToastType = 'success' | 'error' | 'info' export interface Toast { id: string type: ToastType message: string duration: number } export const $toasts = createStore<Toast[]>([]) export const addToast = createEvent<Omit<Toast, 'id'>>() export const removeToast = createEvent<string>() $toasts.on(addToast, (toasts, toast) => [...toasts, { ...toast, id: crypto.randomUUID() }]) $toasts.on(removeToast, (toasts, id) => toasts.filter((t) => t.id !== id)) // Удобный API: toast.success('OK'), toast.error('Oops'), toast.info('FYI') const makeAdder = (type: ToastType, defaultDuration: number) => (message: string, duration = defaultDuration) => addToast({ type, message, duration }) export const toast = { success: makeAdder('success', 4000), error: makeAdder('error', 6000), info: makeAdder('info', 3000), } export { addToast, removeToast }
Тесты стора тостов
Важно:
toast.success— это обычная функция-обёртка, не effector unit.allSettledпринимает только Event/Effect. Поэтому тестируем черезaddToast— нижележащее событие, котороеtoast.successвызывает внутри.
ts// app/features/toasts/model.test.ts import { describe, it, expect, vi } from 'vitest' import { fork, allSettled } from 'effector' import { $toasts, addToast, removeToast } from './model' // Мокаем crypto.randomUUID для предсказуемых id vi.stubGlobal('crypto', { randomUUID: () => 'test-id' }) describe('toasts store', () => { it('adds toast via addToast event', async () => { const scope = fork() await allSettled(addToast, { scope, params: { type: 'success', message: 'Saved', duration: 4000 }, }) const toasts = scope.getState($toasts) expect(toasts).toHaveLength(1) expect(toasts[0].type).toBe('success') expect(toasts[0].message).toBe('Saved') }) it('removes toast by id', async () => { const scope = fork() await allSettled(addToast, { scope, params: { type: 'error', message: 'Failed', duration: 6000 }, }) await allSettled(removeToast, { scope, params: 'test-id' }) expect(scope.getState($toasts)).toHaveLength(0) }) })
React-компонент ToastContainer
tsx// app/features/toasts/view.tsx 'use client' import { useEffect } from 'react' import { useUnit } from 'effector-react' import { $toasts, removeToast } from './model' import { Text } from '@myorg/ui-kit' export function ToastContainer() { const toasts = useUnit($toasts) const remove = useUnit(removeToast) return ( <div role="region" aria-label="Уведомления" style={{ position: 'fixed', top: 'var(--s-md)', right: 'var(--s-md)', zIndex: 'var(--z-toast)' }}> {toasts.map((t) => ( <ToastItem key={t.id} {...t} onClose={() => remove(t.id)} /> ))} </div> ) } function ToastItem({ id, type, message, duration, onClose }: { id: string; type: string; message: string; duration: number; onClose: () => void }) { useEffect(() => { const timer = setTimeout(onClose, duration) return () => clearTimeout(timer) }, [duration, onClose]) const bg = type === 'error' ? 'var(--c-danger)' : type === 'success' ? 'var(--c-success)' : 'var(--c-surface-2)' return ( <div role="status" onClick={onClose} style={{ background: bg, color: 'white', padding: 'var(--s-sm) var(--s-md)', borderRadius: 'var(--radius-md)', marginBottom: 'var(--s-xs)', cursor: 'pointer', }}> <Text>{message}</Text> </div> ) }
4.5 Виджет ChatInput (форма + стор тостов)
Соединяем Textarea, Button и стор тостов:
tsx// app/features/chat-input/view.tsx 'use client' import { useState } from 'react' import { useUnit } from 'effector-react' import { toast } from '@/features/toasts/model' import { Textarea, Button, Box } from '@myorg/ui-kit' export function ChatInput() { const [message, setMessage] = useState('') const showToast = useUnit(toast.success) const handleSubmit = () => { if (!message.trim()) { toast.error('Сообщение не может быть пустым') return } // В М8 здесь будет эффект отправки на сервер showToast('Сообщение отправлено (mock)') setMessage('') } return ( <Box direction="column" gap="sm" padding="md"> <Textarea id="chat-input" placeholder="Напишите сообщение..." value={message} onChange={(e) => setMessage(e.target.value)} rows={3} /> <Box justify="end"> <Button variant="primary" onClick={handleSubmit}> Отправить </Button> </Box> </Box> ) }
4.6 Тесты форм
tsx// ui-kit/src/components/Input/Input.test.tsx import { describe, it, expect } from 'vitest' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { Input } from './Input' describe('Input', () => { it('shows error with aria-invalid and role=alert', () => { render(<Input id="test" error="Required" />) expect(screen.getByRole('alert')).toHaveTextContent('Required') expect(screen.getByRole('textbox')).toHaveAttribute('aria-invalid', 'true') }) it('shows hint when no error', () => { render(<Input id="test" hint="Enter your name" />) expect(screen.getByText('Enter your name')).toBeInTheDocument() expect(screen.queryByRole('alert')).not.toBeInTheDocument() }) it('forwards ref', () => { const ref = { current: null as HTMLInputElement | null } render(<Input ref={ref} id="test" />) expect(ref.current).toBeInstanceOf(HTMLInputElement) }) it('types text', async () => { const user = userEvent.setup() render(<Input id="test" />) await user.type(screen.getByRole('textbox'), 'Hello') expect(screen.getByRole('textbox')).toHaveValue('Hello') }) })
4.7 Storybook stories для форм
tsx// ui-kit/stories/Input.stories.tsx import type { Meta, StoryObj } from '@storybook/react' import { Input } from '../src/components/Input' import { Icon } from '../src/components/Icon' const meta: Meta<typeof Input> = { title: 'Forms/Input', component: Input, args: { id: 'story-input', placeholder: 'Type here...' }, } export default meta type Story = StoryObj<typeof meta> export const Default: Story = {} export const WithLabel: Story = { args: { label: 'Username' } } export const WithError: Story = { args: { label: 'Email', error: 'Invalid email format' } } export const WithHint: Story = { args: { label: 'Email', hint: 'We never share your email' } } export const Disabled: Story = { args: { label: 'Username', disabled: true } } export const WithPrefix: Story = { args: { label: 'Search', prefix: <Icon name="Search" size={16} /> }, } export const Loading: Story = { args: { label: 'Username', loading: true, defaultValue: 'checking...' } }
4.8 Антипаттерны
| Антипаттерн | Почему плохо | Как правильно |
|---|---|---|
| Бизнес-валидация внутри UI Kit | UI Kit перестаёт быть нейтральным | Валидация — в app-слое (стор или виджет) |
| стор в UI Kit (Toast, Form state) | Нарушение нейтральности | Стор — в app/features/, UI Kit — controlled |
useState для данных формы в виджете вместо стора | Не тестируется, не переиспользуется | Если состояние сложное — стор Effector (М8) |
onChange без value (uncontrolled) в публичном API | Непредсказуемое состояние | controlled: value + onChange |
Ошибка без role="alert" | Screen reader не зачитает | role="alert" на тексте ошибки |
disabled без aria-disabled на кастомном контроле | Screen reader не видит состояние | aria-disabled="true" |
Компромиссы (Trade-offs)
| Решение | Плюсы | Минусы |
|---|---|---|
forwardRef (вместо ref-as-prop) | Совместимость с react-hook-form | Чуть больше шаблонного кода |
| Controlled (value+onChange) | Предсказуемое состояние | Меньше производительности на больших формах |
Стор тостов в app/features/ | Переиспользование, тесты | UI Kit остаётся без API тостов |
role="alert" на ошибке | Screen reader зачитает сразу | Может спамить при частых ошибках |
💻 4.9 Практика
Шаг 1. Реализовать Input, Textarea, Select, Checkbox
По образцу выше. Каждый — в ui-kit/src/components/ с .module.css и index.ts.
Шаг 2. Тесты
Напишите Input.test.tsx (4 кейса) и хотя бы 1 кейс на каждый из Textarea, Select, Checkbox.
Шаг 3. Storybook stories
Создайте Input.stories.tsx, Textarea.stories.tsx, Select.stories.tsx, Checkbox.stories.tsx с 3-5 states на каждый.
Шаг 4. Стор тостов + тесты
Создайте app/features/toasts/ (model + test). Тесты через effector/testing.
Шаг 5. ChatInput + ToastContainer
Добавьте виджет ChatInput и компонент ToastContainer на ui-gallery.
Шаг 6. Проверка
bashnpm run verify # lint + typecheck + все тесты + build
4.10 Артефакты сдачи М4
| Артефакт | Как получить | Критерий |
|---|---|---|
verify зелёный | npm run verify | Все тесты форм и стора тостов проходят |
| Скриншот Storybook | :6006, Forms/Input | 7 states (Default, Label, Error, Hint, Disabled, Prefix, Loading), axe зелёный |
| Скриншот ChatInput | :3000/ui-gallery | Ввод текста → клик «Отправить» → toast.success появляется |
| Скриншот error state | :3000/ui-gallery | Пустой ввод → клик → toast.error «Сообщение не может быть пустым» |
4.11 Чек-лист перед Модулем 5
- Input, Textarea, Select, Checkbox реализованы с
forwardRefи ARIA. - Тесты покрывают: рендер, error, ref, user input (через
userEvent). - Стор тостов (
$toasts,toast.success/error/info) работает и протестирован. - ChatInput + ToastContainer добавлены на
ui-gallery. - Все forms-компоненты имеют stories с states, axe зелёный.
- Я понимаю разницу между controlled и uncontrolled пропсами.
Вперёд → Модуль 5: составные компоненты чата.
🧩 Проверь себя
🔗 Мостик к Модулю 5
Формы готовы: Input, Textarea, Select, Checkbox с ARIA, стор тостов на Effector. Теперь соберём из них молекулы: Avatar, MessageBubble, Attachment — с i18n-утилитами для русского языка.