{ "index": 204, "slug": "editorial-2022-05-practice-design-system", "title": "Маленькая дизайн-система: как закрепить контракт кнопки", "excerpt": "Кнопки расходятся по цвету, фокусу и поведению, когда их контракт разбросан по разным слоям. Разбираем малую схему с токенами, состояниями, проверками и обратимой правкой.", "contentHtml": "
Проблема: на одном экране primary button получает цвет из локального CSS, на другом теряет видимый фокус, а на третьем остаётся доступной во время загрузки; пользователь видит непредсказуемое действие, а команда платит повторной правкой и риском сломать соседний сценарий.
\nПричина обычно не в том, что в проекте мало компонентов. У одной кнопки нет общего проверяемого контракта. Цвет живёт в стилях, состояние — в обработчике, текст — в разметке, а список мест использования никто не держит. Поэтому безопасная на вид замена цвета может затронуть оплату, профиль и диалог одновременно.
\nНачинайте с одной повторяющейся primary button. Зафиксируйте её роль, значения, состояния и известные места применения. Такой малый contract не заменяет всю дизайн-систему. Он ограничивает изменение так, чтобы его можно было проверить и откатить.
\nКаталог компонентов полезен, когда команда уже несколько раз приняла одно и то же решение. До этого каталог часто только прячет расхождения за общим названием. Две кнопки могут называться Button, но иметь разные правила loading, разные accessible name и разные последствия для отправки формы.
Малый контракт отвечает на четыре вопроса. Какое значение меняется? Какие состояния поддерживает control? Как пользователь понимает его смысл? Какие существующие места попадут под правку? Если на один вопрос нет ответа, компонент ещё нельзя считать единым.
\nРазделите данные на четыре слоя. Named tokens хранят значения и их роль. State matrix перечисляет допустимые состояния. Semantic fields описывают имя и поведение control. Usage inventory показывает известный радиус изменения. Каждый слой проверяется отдельно, но вместе они дают узкий договор.
\nДля primary button достаточно начать с пяти token names: button.primary.background, button.primary.foreground, button.primary.focus-ring, button.primary.radius и button.primary.gap. Названия в примере — учебное соглашение. В настоящем проекте они должны соответствовать принятой системе именования.
State matrix может содержать default, hover, focus-visible, disabled и loading. Не каждое действие обязано поддерживать каждую ветку. Но отсутствие состояния должно быть решением. Если операция синхронная и loading ей не нужен, это нужно записать. Пустая ветка, которую команда просто забыла, станет дефектом позже.
Semantic fields не выводятся из цвета. Серый цвет может означать disabled, loading, неактивную вкладку или случайный override. Контракт хранит имя действия, роль, объявленное состояние и признак disabled отдельно. Для обычного действия выбирайте native button: браузер уже даёт базовую роль и клавиатурную модель.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Один синий цвет задан разными hex-значениями | Значение не связано с ролью компонента | Найти literal values и сравнить их usage | Ввести один named token для primary button |
| Фокус или loading появляется только после жалобы | Состояния не объявлены заранее | Сверить state matrix с кодом и сценариями | Добавить состояние или явно исключить его |
| Иконка выглядит как кнопка, но действие неясно | Имя control выводят из картинки | Проверить текстовое имя и native semantics | Оставить текст или задать проверяемое accessible name |
| Правка профиля меняет оплату | Неизвестны точки применения | Составить inventory с контекстом и состоянием | Сузить diff и повторить проверку мест |
CSS Custom Properties дают именованные свойства и подстановку через var(). Браузер понимает их синтаксис, но не знает, что значение относится к primary button и кто отвечает за его изменение. Смысл появляется в соглашении команды и в повторяемом применении.
Например, --button-primary-background можно передать компоненту как CSS custom property. Но это не означает, что любой синий фон должен использовать тот же token. Primary, destructive и secondary button имеют разные роли, даже если сегодня два значения совпадают. Роль помогает менять одно решение без неожиданного каскадного эффекта.
При проверке ищите четыре связи: имя token, значение, владелец и потребители. Если рядом с компонентом появляется новый #2457D6, решите, что это: новый вариант, локальное исключение или обход существующего contract. Временное значение тоже получает срок и способ отката. Иначе временное исключение быстро станет вторым стандартом.
Разметка должна сохранять смысл действия независимо от цвета и иконки. Этот фрагмент учебный: он показывает границу контракта, а не готовую библиотеку.
\n<button\n type=\"submit\"\n class=\"button button--primary\"\n data-state=\"default\"\n>\n Сохранить изменения\n</button>\nВ реальном интерфейсе нужно проверить переходы default → loading → default или error. Пока запрос выполняется, повторная отправка должна иметь явное правило. После ошибки пользователь должен понимать, что произошло и какое действие доступно дальше. Эти решения нельзя прятать только в opacity.
Если вместо native element используется div role=\"button\", одной ARIA-роли недостаточно. Понадобятся клавиатурное управление, focus behavior, disabled semantics и проверка имени. Если custom host не даёт нужного поведения, это отрицательный результат: вернитесь к native button, а не добавляйте ещё один слой стилизации.
Сначала можно проверить сам контракт: все ли обязательные token names объявлены, все ли состояния перечислены, есть ли у control имя и роль, заполнены ли известные usage. Такой тест быстро ловит пропущенное поле и не требует рендера.
\nconst requiredStates = [\n 'default', 'hover', 'focus-visible', 'disabled', 'loading'\n];\n\nconst contract = {\n name: 'Сохранить изменения',\n role: 'button',\n state: 'default',\n permittedStates: requiredStates\n};\n\nconst missingStates = requiredStates.filter(\n (state) => !contract.permittedStates.includes(state)\n);\n\nconst ok = missingStates.length === 0\n && contract.role === 'button'\n && Boolean(contract.name);\nРезультат true здесь означает только согласованность объекта. Код не создаёт DOM, не собирает CSS, не измеряет контраст, не проверяет порядок фокуса и не говорит, что screen reader произнесёт ожидаемое имя. Не называйте такую проверку visual regression или доказательством соответствия WCAG. Для этих утверждений нужна наблюдаемая страница и отдельный артефакт.
Предположим, три формы используют разные обязательные поля, цвета ошибок и правила disabled. Общий token не исправит проблему. Эти формы могут выглядеть похоже, но иметь разные state matrix и разные требования к имени. В таком случае не расширяйте primary button новым набором optional props только ради повторного названия.
\nСначала зафиксируйте различие в поведении. Если оно устойчиво, создайте отдельную роль или variant с собственным contract. Если различие появилось из-за случайного override, удалите override и верните control к исходному contract. Это дешевле, чем превращать исключение в универсальный API.
\nМалый contract не строит темизацию, не мигрирует legacy CSS, не выбирает типографику бренда и не доказывает, что найден каждый usage. Он также не заменяет ручную проверку доступности и visual comparison. Значения, имена и места из примеров учебные. Production-результат можно утверждать только после реального запуска и сохранённого наблюдения.
\nКритерий готовности проверяемый: для выбранной primary button каждый обязательный state имеет решение; каждый token имеет имя и место потребления; каждый найденный usage имеет name, role и state; проверка данных проходит; реальная страница отдельно подтверждает keyboard, focus-visible, DOM semantics и visual diff; rollback возвращает прежнее значение. Если хотя бы один пункт неизвестен, contract не готов.
\nvar().