Storybook
Своего Storybook у библиотеки нет и не будет. Как подключить компоненты в ваш — конфиг, тема, оверлеи и главная ловушка.
Своего Storybook у Granularity нет, и он не планируется. За вопросом «а Storybook у вас есть?» почти всегда стоят два других: есть ли у компании единая витрина компонентов и встраивается ли библиотека в её процессы визуального тестирования. Ответ на оба — да, и эта страница про то, как.
Почему не свой
Портал уже делает то, ради чего Storybook заводят: живые демо на странице каждого компонента, таблицу API прямо из кода, playground с изменяемыми пропами и проверку доступности. Второй такой инструмент рядом означал бы второй источник правды и дублирование сотен демо — при одном мейнтейнере это прямая дорога к расхождению, а расходятся такие пары молча.
То же относится к Chromatic и Histoire: своего не строится.
Свой Storybook у вас при этом остаётся осмысленным — но по другой причине. В нём живут ваши компоненты, собранные из наших, и витрина нужна именно им.
Что подключить
Granularity ничем не отличается от любой другой Vue-библиотеки, кроме одного: её CSS не импортируется файлом, а собирается granum из манифеста пакета (установка). Поэтому вся настройка — про то, чтобы plugin granum отработал внутри Storybook.
Сборка
Storybook на Vite (@storybook/vue3-vite) читает ваш vite.config.ts. Если
плагин granum уже стоит там, добавлять в .storybook/main.ts нечего:
import { defineConfig } from 'vite'
import Vue from '@vitejs/plugin-vue'
import { granum } from '@feugene/granum/vite'
import granumConfig from './granum.config.mjs'
export default defineConfig({ plugins: [Vue(), granum(granumConfig)] })Если конфиг у Storybook свой, плагин добавляется через viteFinal:
import { granum } from '@feugene/granum/vite'
import granumConfig from '../granum.config.mjs'
export default {
framework: '@storybook/vue3-vite',
stories: ['../src/**/*.stories.@(ts|tsx)'],
viteFinal: async config => ({
...config,
plugins: [...(config.plugins ?? []), granum(granumConfig)],
}),
}Стили
Те же два импорта, что в точке входа приложения, — в preview.ts, и порядок
между ними такой же несущий:
import '../src/reset.css'
import 'virtual:granum.css'Сброс обязан лежать в слое, объявленном раньше слоёв granum.*, — иначе он
выигрывает у всей библиотеки, и истории отрисуются без рамок и с бесцветными
кнопками. Подробности — в типовых поломках.
Ловушка: истории не входят в appSources
Истории отрисовались, компоненты в них выглядят правильно — а утилитарный класс,
написанный прямо в истории (обёртка на grid gap-4, ширина сцены), не работает.
Классы компонентов приезжают из манифеста пакета и от этого не зависят. А вот
классы вашей разметки granum ищет на диске, в каталогах appSources.dirs.
Если истории лежат вне src, их надо назвать:
import { defineGranumConfig } from '@feugene/granum/vite'
import { windEngine } from '@feugene/granum-engine-wind'
export default defineGranumConfig({
engine: windEngine(),
providers: ['@feugene/granularity'],
themes: { names: ['light', 'dark'] },
appSources: { dirs: ['src', '.storybook'] },
})Прежняя формулировка этой ловушки — «extractor не заглядывает в dist пакета» —
к granum не относится: классы компонентов посчитаны на сборке пакета и лежат в
манифесте, а файлы пакета granum перечитывает сам, только если словарь вашего
движка разошёлся с пакетным. От appSources это не зависит. Осталась ровно
половина проблемы, и она про ваш код, а не про чужой.
Перечислять components в конфиге Storybook обычно не нужно: витрина по смыслу
показывает всё, а вес CSS в ней никого не волнует. Сужение списка — приём
боевого приложения, и там оно остаётся.
Обёртка историй
Дефолты поддерева и цель для оверлеев задаёт GrConfigProvider. В Storybook он
живёт в декораторе:
import { GrConfigProvider } from '@feugene/granularity/components/GrConfigProvider'
import { h } from 'vue'
export const decorators = [
(story: () => unknown) => ({
setup: () => () => h(GrConfigProvider, { size: 'md' }, () => h(story() as never)),
}),
]Провайдер рендерится прозрачно (display: contents) и раскладку истории не
меняет, поэтому его можно ставить на все истории разом.
Оверлеи
Модалки, дропдауны и тултипы телепортируются в общий портал — #gr-portal в
body, который пакет создаёт сам на клиенте при первом рендере телепортирующего
компонента. В Storybook это работает без настройки: body у превью свой, и
портал появится в нём.
Настройка нужна, только если вы указываете portalTarget своим контейнером.
Тогда требование к нему то же, что в приложении, и оно жёсткое: никаких
transform, filter, contain, perspective и will-change — они создают
containing block для position: fixed, и панели начнут считать позицию от
контейнера, а не от вьюпорта. Декораторы Storybook такие обёртки создают охотно.
Переключение темы
Тема — атрибут на корне документа, и переключается она так же, как в приложении:
export const globalTypes = {
theme: {
toolbar: {
items: [
{ value: 'light', title: 'Light' },
{ value: 'dark', title: 'Dark' },
],
},
},
}
export const decorators = [
(story: () => unknown, context: { globals: { theme?: string } }) => {
document.documentElement.dataset.theme = context.globals.theme ?? 'light'
return story()
},
]Декораторы обоих фрагментов живут в одном массиве decorators: второе
export const decorators в том же файле не соберётся.
Обе темы обязаны попасть в CSS — за это отвечает themes.names в конфиге выше:
без него granum берёт темы, которые ядро объявило умолчательными, а это одна
light. Указав там только light или не указав поле вовсе, вы получите
переключатель, который меняет атрибут и не меняет ничего больше.
Импорты в историях
Резолвер авто-импорта (@feugene/unplugin-granularity) работает в шаблонах SFC.
История на TypeScript — не шаблон, и компонент в ней импортируется подпутём:
import { GrButton } from '@feugene/granularity/components/GrButton'
export default { component: GrButton }
export const Primary = { args: { variant: 'primary', tone: 'primary' } }Подпуть, а не корневой импорт: он и есть весь смысл гранулярности — в бандл
истории попадает один компонент, а не пакет. Компонент из корня granum к тому же
отвергает ошибкой сборки ComponentRootImportError.
Что взять с портала, а не переписывать
- Аргументы историй — таблица API на странице компонента порождается из
кода, включая типы и значения по умолчанию. Копировать её в
argTypesруками незачем: Storybook выведет большую часть сам из типов SFC. - Проверка доступности — подпуть
@feugene/granularity/testingдаёт окружение для монтирования и уборку между тестами, а axe и Playwright подключаются вашими. Подробности — на странице тестирования.@feugene/granularity-test-kitв основном про контрактные тесты пакетов семейства, но его./a11y(axe в jsdom) и./e2e(обход клавиатурой и axe в Playwright) пригодны и приложению. - Клавиатурные контракты — описаны в репозитории библиотеки по компонентам, и история их не заменяет: они про поведение, а не про вид.