Поле ввода, подпись и сообщение об ошибке
Кнопка умеет выполнять действие, однако настоящая форма начинается с ввода данных. Добавим TextField в «Набор»: подпись, input, пояснение и сообщение об ошибке. Основная задача этого урока — связать элементы через устойчивые идентификаторы и отделить значение поля от его оформления. Красная рамка сама по себе не объясняет ошибку и не устанавливает связь сообщения с вводом.
В проекте используем поле «Название организации». Пользователь должен понимать, что вводит и какое ограничение действует. При отсутствии значения приложение может передать ошибку «Укажите название». Сам компонент не решает, когда такая ошибка возникает: он предоставляет разметку для переданного состояния. Это позволяет применять одно поле в разных формах, не встраивая внутрь правила конкретной организации или сервера.
Видимая подпись и идентификатор
TextFieldProps наследует нативные атрибуты input и добавляет обязательный label, необязательные hint и error. Компонент вызывает React useId при каждом рендере в установленном порядке хуков. Если потребитель передал собственный id, используется он; иначе берётся сгенерированный. Такой подход позволяет связать label с input, не выдумывая глобальный счётчик и не используя случайное число при каждом обновлении.
Полный файл src/TextField.tsx для этого состояния:
import { useId, type InputHTMLAttributes } from 'react';
export interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
hint?: string;
error?: string;
}
export function TextField({ label, hint, error, id, 'aria-describedby': extraDescription, ...props }: TextFieldProps) {
const generated = useId();
const inputId = id ?? generated;
const hintId = `${inputId}-hint`;
const errorId = `${inputId}-error`;
const describedBy = [extraDescription, hint ? hintId : undefined, error ? errorId : undefined].filter(Boolean).join(' ') || undefined;
return <div className="nabor-field">
<label htmlFor={inputId}>{label}</label>
<input {...props} id={inputId} aria-describedby={describedBy} aria-invalid={error ? true : props['aria-invalid']} />
{hint && <p id={hintId}>{hint}</p>}
{error && <p id={errorId} className="nabor-error">{error}</p>}
</div>;
}
Полный файл src/TextField.stories.tsx для этого состояния:
import { useState } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import { TextField } from './TextField';
const meta = { title: 'Nabor/TextField', component: TextField, tags: ['autodocs'], args: { label: 'Название организации', hint: 'От двух до восьмидесяти символов' } } satisfies Meta<typeof TextField>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Invalid: Story = { args: { error: 'Укажите название', required: true } };
function Editable() { const [value,setValue]=useState('Контур'); return <TextField label="Название организации" value={value} onChange={(event)=>setValue(event.target.value)} />; }
export const Controlled: Story = { render: () => <Editable /> };
Атрибут htmlFor у label указывает идентификатор input. Ожидаемое нативное поведение: нажатие подписи переводит фокус на связанное поле. Это не обработчик, написанный вручную в компоненте. Видимая подпись остаётся и при введённом значении. Поэтому placeholder нельзя считать равной заменой: он исчезает или меняется при вводе и не выражает тот же устойчивый договор. При необходимости placeholder дополняет label, но не отменяет его.
Из inputId строятся идентификаторы пояснения и ошибки. Они нужны только для существующих элементов: если hint отсутствует, его идентификатор не должен попадать в описание. Мы фильтруем пустые значения и соединяем оставшиеся через пробел. aria-describedby допускает несколько идентификаторов; порядок помогает сформировать последовательность пояснений. Ошибку и нейтральную подсказку можно передать одновременно, если это действительно полезно пользователю.
Дополнительное описание потребителя
Потребитель может уже иметь внешнее пояснение к полю и передать свой aria-describedby. Компонент сохраняет его, добавляя собственные связи. Иначе библиотека незаметно отрезала бы важную инструкцию страницы. Это хороший пример наследования нативного контракта: недостаточно просто использовать spread, если компонент переопределяет конкретный атрибут. Надо решить, заменять его или композиционно объединять.
Если передан error, aria-invalid устанавливается в true. Если ошибки нет, сохраняется нативное значение, полученное от потребителя. Такая семантика означает наличие недопустимого ввода, но не сообщает причину сама по себе. Причина остаётся в связанной строке текста. Пользователь не должен выводить её из одного цвета. Мы намеренно не объявляем каждую ошибку отдельным role="alert": выбор времени объявления зависит от поведения настоящей формы.
Собственный id полезен при интеграции с внешними ссылками или сложной группой полей, но ответственность за уникальность тогда остаётся у потребителя. Два поля с одинаковым переданным идентификатором создадут неоднозначную связь label и описаний. Автоматическая генерация помогает стандартному случаю; она не может исправить намеренно дублированный внешний идентификатор. В истории стоит показать два экземпляра рядом, если библиотека часто используется в такой композиции.
Управляемый и свободный ввод
В статической истории Default поле использует нативный ввод без собственного React-состояния. История Controlled содержит компонент Editable, который хранит строку через useState и обновляет её в onChange. Здесь value и обработчик образуют пару. Если передать фиксированное value без обновления, пользователь не сможет изменить отображаемое значение. Это свойство управляемого React-input, а не ошибка токенов или CSS.
Нельзя условно переключать поле между отсутствующим значением и управляемой строкой без понимания модели. Для нашей формы начальное состояние — пустая или заданная строка, а каждое событие обновляет её. Если данные приходят позже, приложение выбирает явное начальное значение. Поле библиотеки не загружает организацию и не решает, как объединять серверный ответ с уже введённым текстом пользователя. Это другая задача, которая остаётся вне компонента.
Ошибка как состояние формы
История Invalid передаёт конкретную ошибку и признак required. Эти входы не означают, что Storybook сам проверил бизнес-правила. Они описывают ожидаемое состояние после решения приложения. При самостоятельном будущем просмотре читатель должен увидеть видимую подпись, input и текст сообщения; связи можно сопоставить с DOM. В редакционной работе эти браузерные действия не выполнялись и не записывались как результат теста.
Сообщение «Введите корректные данные» слишком общее для данного поля. Лучше назвать доступное действие: «Укажите название» или «Введите от 2 до 80 символов». Компонент принимает строку, но библиотечная документация может требовать такой конкретности. Это связывает технический интерфейс с удобством исправления. Пользователь не должен пробовать случайные значения только потому, что разработчик вывел красный текст.
Теперь TextField связывает видимую подпись, ввод и описания, поддерживая управляемое значение и нативные атрибуты. Дальнейшая форма сможет использовать этот договор без копирования доступной разметки в каждом месте. Следующий урок добавит диалог: там потребуется согласовать нативный жизненный цикл, управление фокусом и React-состояние открытия. Мы сохраним тот же принцип — библиотека выражает механизм, приложение определяет смысл операции.
Передача autocomplete и имени поля
Нативные параметры name, autoComplete, inputMode и type остаются доступны через наследование. Они помогают интегрировать поле в настоящий пользовательский ввод. Компонент не должен выбирать универсальный autocomplete для всех полей: название организации и адрес электронной почты имеют разные назначения. Потребитель передаёт осмысленный параметр, а библиотека сохраняет его на input.
Если приложение хочет прочитать значение через обычный FormData, оно задаёт name. label отвечает за название для пользователя, но не автоматически за ключ отправки. Это разные пространства имён. Присвоить всем полям одинаковое name ради удобства разметки означает изменить структуру данных формы. Учебный TextField не скрывает эти нативные возможности и не подменяет их собственным неясным протоколом.
Поле с ошибкой не должно менять свою идентичность при каждом символе. Если генерировать новый id через случайное число, label и внешнее описание будут получать новую связь без необходимости. useId выражает устойчивую принадлежность экземпляру React, а переданный id позволяет приложению задать известный договор.
Оглавление серии, общая подборка и полный архив исходников. Для этого урока выберите lesson-05.
Первичные источники: Storybook CSF, React Vite integration, Аргументы историй, Темы и globals, Декораторы, Accessibility addon, Автоматическая документация, React useId, HTML dialog.
Редакционный статус: локальный черновик. Учебные команды, приложение и проверки не запускались; описанные результаты ожидаемые. Профиль версий закреплён, разрешённый lockfile не создавался.