Перейти к содержанию

Светлая и тёмная темы через декоратор

«Набор» использует семантические токены, поэтому можно добавить вторую тему без переписывания каждого компонента. Создадим светлую и тёмную области оформления и переключатель в Storybook. Учебная цель — провести тему через общий декоратор и показать, какие элементы зависят от наследования CSS. Настройка панели самого Storybook является отдельной задачей и в данном уроке не смешивается с темой компонентов.

Тема задаёт конкретные значения для уже известных ролей: поверхность, основной текст, пояснение, акцент и контур. Она не меняет назначение primary или danger. Если в тёмном варианте ошибки выглядят другим оттенком, они всё равно описывают ошибку. Это свойство семантического словаря позволяет новым компонентам наследовать согласованное оформление, а не иметь собственную систему переключения света.

Область темы

В CSS добавлен селектор .nabor-theme[data-theme="dark"]. Он переопределяет те же переменные, что объявлены в исходном корне. Если атрибут отсутствует или равен light, действуют светлые значения. Потомки читают ближайшие значения через var. Можно иметь две разные области рядом, если продукт действительно требует такую композицию; для мастерской достаточно одной области вокруг истории.

Полный файл .storybook/preview.tsx для этого состояния:

import type { Preview } from '@storybook/react-vite';
import '../src/styles.css';
const preview: Preview = {
  initialGlobals: { theme: 'light' },
  globalTypes: { theme: { description: 'Тема компонентов', toolbar: { title: 'Тема', icon: 'circlehollow', items: [{value:'light',title:'Светлая'},{value:'dark',title:'Тёмная'}] } } },
  decorators: [(Story,context) => <div className="nabor-theme" data-theme={context.globals.theme === 'dark' ? 'dark' : 'light'} style={{ padding:'1rem',minHeight:'100vh' }}><Story /></div>],
};
export default preview;

Полный файл src/styles.css для этого состояния:

.nabor-theme {
  --color-surface: #ffffff;
  --color-text: #20242c;
  --color-muted: #475569;
  --color-primary: #6335b7;
  --color-on-primary: #ffffff;
  --color-danger: #ad1833;
  --color-line: #8993a5;
  --space-small: .5rem;
  --space-regular: 1rem;
  --radius-control: .5rem;
  color: var(--color-text);
  background: var(--color-surface);
  font-family: system-ui, sans-serif;
}
.nabor-button { font: inherit; padding: .65rem 1rem; border: 1px solid transparent; border-radius: var(--radius-control); cursor: pointer; }
.nabor-button--primary { color: var(--color-on-primary); background: var(--color-primary); }
.nabor-button--secondary { color: var(--color-text); background: var(--color-surface); border-color: var(--color-line); }
.nabor-button--danger { color: #fff; background: var(--color-danger); }
.nabor-button--compact { padding: .4rem .65rem; }
.nabor-button:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 3px; }
.nabor-button:disabled { opacity: .65; cursor: default; }
.nabor-field { display: grid; gap: .4rem; max-width: 28rem; }
.nabor-field input { font: inherit; padding: .65rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-line); border-radius: var(--radius-control); }
.nabor-field input:focus-visible { outline: 3px solid var(--color-primary); outline-offset: 2px; }
.nabor-error { color: var(--color-danger); }
dialog { color: inherit; background: var(--color-surface); border: 1px solid var(--color-line); border-radius: var(--radius-control); max-width: min(32rem, 90vw); }
dialog::backdrop { background: #0008; }
 .nabor-notice { padding: 1rem; border: 1px solid var(--color-line); border-radius: var(--radius-control); }
.nabor-notice--error { border-color: var(--color-danger); }
.nabor-theme[data-theme="dark"] {
  --color-surface: #1c2028;
  --color-text: #f0f2f7;
  --color-muted: #bac4d2;
  --color-primary: #b99af4;
  --color-on-primary: #1c1230;
  --color-danger: #f47994;
  --color-line: #78879b;
}
.nabor-theme[data-theme="dark"] .nabor-button--danger { color: #240812; }

Тёмная поверхность требует другого текста и контуров. Нельзя заменить только фон и оставить обычный тёмный текст. Пара --color-primary и --color-on-primary тоже изменяется вместе. У опасной кнопки в нашем исходном CSS был отдельный белый текст, поэтому для тёмной темы добавлено специальное правило. Этот пример показывает цену обхода семантической пары: исключение надо учитывать явно. Более зрелая библиотека может ввести --color-on-danger.

Новые числа цветов являются выбранным учебным набором. Автор не измерял их контраст и не заявляет соответствие всей библиотеки требованиям доступности. Проверка нужна для фактических пар текста и поверхности, включая состояние фокуса и отключение. Тема создаёт механизм согласованного изменения, но не доказывает качество самих значений. Это различие поможет читателю не принимать красивый переключатель за завершённую дизайн-систему.

Globals и toolbar

В preview объявлен globalTypes.theme с двумя вариантами toolbar. initialGlobals задаёт светлый исходный выбор. Декоратор читает context.globals.theme и устанавливает data-theme на обёртке. Таким образом, панель меняет вход мастерской, а CSS решает визуальную интерпретацию. Сам компонент не обращается к глобальной настройке Storybook. Это важно для дальнейшего использования библиотеки вне мастерской.

В декораторе значение приводится к двум известным состояниям: dark либо light. Неизвестная строка не становится произвольным селектором темы. Такой небольшой guard удерживает пример предсказуемым, хотя не является общей системой validation. Если позже добавляется третья тема, контракт toolbar и CSS расширяются согласованно. Нельзя просто добавить пункт в меню, забыв реальные значения переменных для новой области.

Preview подключает общий CSS один раз. Внешний компонент-клиент впоследствии тоже должен подключить его и установить класс корня. Storybook не экспортирует свою обёртку автоматически в приложение. Это распространённая причина ситуации, когда мастерская выглядит правильно, а импортированная кнопка теряет оформление. Документация библиотеки должна показывать обе части интеграции, а не только строку React-import.

Тематическая среда диалога

Нативный dialog находится в верхнем визуальном слое, но сохраняет связь с DOM-деревом, из которого был показан. Поэтому переменные области оформления продолжают влиять на его поверхность. Это отличается от создания React-портала в отдельный узел вне тематического корня. Если будущая библиотека заменит реализацию порталом, ей придётся отдельно обеспечить тематический контекст и CSS-область. Название «модальное окно» не описывает этот технический выбор.

Внутри истории установлены отступ и минимальная высота области. Это облегчает просмотр поверхности, но не является стилем самой кнопки или поля. В реальном продукте геометрию страницы задаёт приложение. Не копируйте учебные minHeight: 100vh во все компоненты библиотеки: такой стиль растянет каждый маленький элемент до целого экрана. Декоратор специально отделяет среду демонстрации от реализации элемента.

Тема компонента и тема документации

У Storybook есть собственная оболочка и оформление документационных страниц. Наш toolbar управляет CSS компонентов через decorator. Он не обещает одновременно изменить весь интерфейс панели и текст вне областей историй. Если нужна тёмная документация целиком, её настраивают отдельно по API выбранной версии. Смешивание этих уровней приводит к ложному выводу, будто одно значение globals должно влиять на всё окно браузера.

Для обычного изучения сначала полезнее рассмотреть один и тот же компонент в двух средах. В историю можно передать фиксированные globals, чтобы сохранить важный тематический пример; общая панель пригодна для исследования. Как и с controls, значимые случаи лучше назвать явно, чем полагаться на случайное состояние toolbar. Например, ошибочное поле в тёмной теме заслуживает отдельного внимания, потому что контур и текст имеют другую пару цветов.

Пользовательское предпочтение операционной системы пока не считывается. В приложении можно связать тему с prefers-color-scheme или профилем пользователя, но это отдельная политика выбора. Библиотека принимает установленную область и обеспечивает оформление. Такое разделение позволяет продукту решать вопросы сохранения выбора, серверной начальной разметки и переключения без зависимости от Storybook. Мастерская остаётся инструментом представления, а не владельцем настроек пользователя.

Теперь одна семантическая библиотека работает через две области значений и общий механизм переключения. Следующий урок посвятим доступности: какие свойства задают HTML и ARIA, что можно подготовить для ручного анализа и почему ни число тем, ни автоматический addon не являются доказательством полного соответствия интерфейса.

Настройка потребителя поверх темы

Приложение может переопределить поддерживаемые переменные в своей области, например выбрать другой основной акцент. Это не требует загрузки Storybook на рабочую страницу. Но если оно переопределяет только фон кнопки, забыв цвет текста, результат может нарушить исходную пару. Документация токенов должна объяснять связанные роли, а потребитель — проверять фактическое сочетание своего бренда.

Не используйте глобальный body.dark как скрытую обязательную зависимость каждого компонента. Наш контракт области позволяет разместить библиотеку в части страницы и не менять остальной сайт. Если продукт хочет целиком тёмный экран, он устанавливает корень на подходящем уровне. Эта свобода является следствием CSS-наследования и ясного селектора, а не специального доступа компонента к настройкам браузерной панели.

Оглавление серии, общая подборка и полный архив исходников. Для этого урока выберите lesson-09.

Первичные источники: Storybook CSF, React Vite integration, Аргументы историй, Темы и globals, Декораторы, Accessibility addon, Автоматическая документация, React useId, HTML dialog.

Редакционный статус: локальный черновик. Учебные команды, приложение и проверки не запускались; описанные результаты ожидаемые. Профиль версий закреплён, разрешённый lockfile не создавался.