Модуль 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 немедленно зачитывает
Hintaria-describedby="{id}-hint"Связывает с подсказкой
Loadingaria-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 KitUI 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. Проверка

bash
npm run verify   # lint + typecheck + все тесты + build

4.10 Артефакты сдачи М4

АртефактКак получитьКритерий
verify зелёныйnpm run verifyВсе тесты форм и стора тостов проходят
Скриншот Storybook:6006, Forms/Input7 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: составные компоненты чата.


🧩 Проверь себя

1. Форма в Effector: валидация на каждое нажатие (combos stores) vs onSubmit — trade-off?
2. Валидация формы — где?
3. Управление формой на Effector — преимущество?
4. Отправка формы через effect.finally (pending → done/fail) — для чего?
5. «Early Effector» в контексте курса — что это?

🔗 Мостик к Модулю 5

Формы готовы: Input, Textarea, Select, Checkbox с ARIA, стор тостов на Effector. Теперь соберём из них молекулы: Avatar, MessageBubble, Attachment — с i18n-утилитами для русского языка.