Storybook

  • Интеграции
  • 6 мин
  • кода: 7

Своего Storybook у библиотеки нет и не будет. Как подключить компоненты в ваш — конфиг, тема, оверлеи и главная ловушка.

Ревизия 2026-10-16

На этой странице

Своего 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 нечего:

vite.config.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:

.storybook/main.ts
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, и порядок между ними такой же несущий:

.storybook/preview.ts
import '../src/reset.css'
import 'virtual:granum.css'

Сброс обязан лежать в слое, объявленном раньше слоёв granum.*, — иначе он выигрывает у всей библиотеки, и истории отрисуются без рамок и с бесцветными кнопками. Подробности — в типовых поломках.

Ловушка: истории не входят в appSources

Истории отрисовались, компоненты в них выглядят правильно — а утилитарный класс, написанный прямо в истории (обёртка на grid gap-4, ширина сцены), не работает.

Классы компонентов приезжают из манифеста пакета и от этого не зависят. А вот классы вашей разметки granum ищет на диске, в каталогах appSources.dirs. Если истории лежат вне src, их надо назвать:

granum.config.mjs
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 он живёт в декораторе:

.storybook/preview.ts
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 такие обёртки создают охотно.

Переключение темы

Тема — атрибут на корне документа, и переключается она так же, как в приложении:

.storybook/preview.ts
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 — не шаблон, и компонент в ней импортируется подпутём:

src/stories/Button.stories.ts
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) пригодны и приложению.
  • Клавиатурные контракты — описаны в репозитории библиотеки по компонентам, и история их не заменяет: они про поведение, а не про вид.