Контракт кнопки и варианты оформления
Токены задают язык оформления, но кнопка пока имеет узкий интерфейс. Теперь сделаем Button полноценным элементом библиотеки: добавим варианты, размеры, нативные атрибуты и предсказуемое построение класса. Цель — дать потребителю достаточно возможностей для обычной формы, сохранив ясную границу. Кнопка выполняет действие; переход по адресу остаётся задачей ссылки.
У интерфейсной библиотеки всегда есть два уровня параметров. Нативный HTML-элемент поддерживает disabled, type, обработчики, доступное имя и множество других атрибутов. Библиотека добавляет свои семантические варианты, например primary и danger. Если переписать все HTML-возможности вручную, легко забыть полезный атрибут. Если дать произвольную строку любого класса вместо вариантов, теряется общий визуальный договор.
Расширение нативного контракта
ButtonProps наследует ButtonHTMLAttributes<HTMLButtonElement>. Это даёт типизацию стандартных атрибутов и обработчиков. Дополнительный variant ограничен тремя значениями, size — двумя. Отсутствие variant выбирает primary, отсутствие размера — regular. Значения по умолчанию важны для совместимости: существующие потребители с одним текстом продолжают получать осмысленную кнопку.
Полный файл src/Button.tsx для этого состояния:
import type { ButtonHTMLAttributes } from 'react';
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger';
size?: 'regular' | 'compact';
}
export function Button({ variant = 'primary', size = 'regular', className = '', type = 'button', ...props }: ButtonProps) {
return <button {...props} type={type} className={`nabor-button nabor-button--${variant} nabor-button--${size} ${className}`} />;
}
Пропс type явно получает значение button, хотя нативная кнопка внутри формы по умолчанию может отправлять её. Мы выбираем безопасный договор библиотеки: отправка задаётся потребителем явно через type="submit". Это не запрет отправки, а отказ от неожиданного поведения. При интеграции в настоящую форму автор знает, какой элемент выполняет операцию отправления, и не зависит от положения компонента в дереве.
Остальные параметры передаются через spread. Порядок имеет значение: сформированный className и выбранный type должны быть теми, которые вернёт компонент. Поэтому собственные параметры вынесены из объекта, а нативные переданы на элемент. Это предотвращает случайное повторное переопределение класса из остаточного объекта. Возможность внешнего className сохранена как расширение, но она не должна разрушать основной контракт семантического варианта.
Вариант отвечает за назначение
primary используется для главного действия, secondary для сопутствующего, danger для действия с существенными последствиями. Названия описывают смысл, а не цвет. Если бренд поменяется, опасное действие может сохранить роль при другом оттенке. Потребителю не нужно переписывать purple на blue во всех формах. Однако одна красная кнопка не объясняет последствия удаления: действие должно иметь понятный текст и, при необходимости, отдельную процедуру подтверждения.
Размер compact уменьшает внутренние отступы. Он не обещает, что кнопка подходит любому плотному интерфейсу. Доступность цели зависит от фактической области и соседних элементов. Нельзя автоматически выбирать самый маленький вариант для мобильного экрана, где касание часто требует большего пространства. В нашей библиотеке размер является контролируемой возможностью, а выбор контекста остаётся обязанностью потребителя.
Стили вариантов используют уже введённые токены. Вторичный вариант получает поверхность и контур, основной — акцентную пару. Контур фокуса задаётся отдельно через :focus-visible. Его наличие не должно зависеть от того, какой вариант сейчас выбран. Удалять outline ради аккуратного вида нельзя без равноценной видимой замены. Пользователь клавиатуры должен различать текущий элемент, а не угадывать его положение по порядку текста.
Отключённое действие
Нативный disabled предотвращает взаимодействие и меняет участие кнопки в обычном клавиатурном маршруте. Это существенно отличается от серого цвета. Стиль opacity лишь визуально сопровождает уже заданную недоступность. Если оставить кнопку активной и только уменьшить прозрачность, она продолжит выполнять действие. Поэтому контракт строится от поведения к оформлению, а не наоборот.
При этом отключение может скрыть для пользователя причину. Если форма не готова, рядом должно быть понятное пояснение, что требуется изменить. Кнопка не должна самостоятельно придумывать это сообщение: она не знает значения полей и правил приложения. Позднее компонент поля и сообщение статуса помогут составить такой экран. Дизайн-система предоставляет механизмы, но смысл конкретной ошибки находится в пользовательской задаче.
Композиция внешнего класса
Параметр className позволяет потребителю добавить внешний отступ или разместить кнопку в сетке. В нашей реализации он добавляется после системных классов в строку. Однако порядок слов в HTML-классе сам по себе не определяет победителя в CSS-каскаде: важны специфичность и порядок правил. Не обещайте, что «последний класс всегда побеждает». Если потребитель переопределяет внутренний цвет слишком общим селектором, результат надо разбирать по каскаду.
Для повседневной интеграции лучше задавать расстояния контейнером формы, а не менять внутреннюю геометрию кнопки. Тогда один компонент сохраняет предсказуемый вид в разных местах. Если потребности повторяются, библиотека может ввести дополнительный поддерживаемый вариант. Постоянное ручное переопределение одних и тех же правил является сигналом, что интерфейс не выражает важный сценарий.
Рассмотрим ошибочный пример: использовать Button как ссылку через обработчик изменения location.href. Визуально переход произойдёт, но нативные возможности ссылки — открытие в новой вкладке, копирование адреса, семантика назначения — будут потеряны. Наш контракт не включает полиморфный as, поэтому для такого сценария нужен отдельный элемент ссылки с согласованными стилями. Универсальная кнопка не должна становиться скрытой заменой всех интерактивных тегов.
Теперь Button имеет небольшой, но настоящий интерфейс: семантические варианты, размеры и нативные возможности. Следующий урок покажет, как записать эти состояния в историях, использовать управляемые аргументы и описать пограничные подписи. Документация станет набором конкретных примеров одного компонента, а не картинкой одной счастливой кнопки.
Нативный обработчик и библиотечное правило
Обработчик клика получает обычное React-событие кнопки, потому что пропсы наследуют нативный тип. Приложение может использовать его, если это нужно, но компонент не заставляет каждое действие зависеть от деталей DOM. В большинстве форм достаточно вызвать смысловую функцию сохранения. Это удерживает бизнес-логику вне стилей и вариантов кнопки.
При добавлении будущего пропса подумайте о прежнем потребителе. Необязательное значение с сохранённым default обычно проще внедрить, чем новый обязательный параметр во всех формах. Однако совместимость не сводится к наличию вопросительного знака в TypeScript. Если прежняя кнопка начинает иначе отправлять форму или менять фокус, это затронет пользователя. Документируйте именно наблюдаемое изменение, которое следует из нового договора компонента.
При передаче формы в Storybook используйте обычный submit-обработчик формы, а не два одинаковых обработчика кнопки и формы. Иначе одно пользовательское действие может вызвать две смысловые операции. Библиотечный Button сохраняет нативную возможность отправки, поэтому модель событий остаётся явной для потребителя.
Оглавление серии, общая подборка и полный архив исходников. Для этого урока выберите lesson-03.
Первичные источники: Storybook CSF, React Vite integration, Аргументы историй, Темы и globals, Декораторы, Accessibility addon, Автоматическая документация, React useId, HTML dialog.
Редакционный статус: локальный черновик. Учебные команды, приложение и проверки не запускались; описанные результаты ожидаемые. Профиль версий закреплён, разрешённый lockfile не создавался.