# Storybook

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

- Page: https://granularity.tech/ru/docs/storybook
- Last reviewed: 2026-10-16

Своего Storybook у Granularity нет, и он не планируется. За вопросом «а
Storybook у вас есть?» почти всегда стоят два других: **есть ли у компании
единая витрина компонентов** и **встраивается ли библиотека в её процессы
визуального тестирования**. Ответ на оба — да, и эта страница про то, как.

## Почему не свой

Портал уже делает то, ради чего Storybook заводят: живые демо на странице
каждого компонента, таблицу API прямо из кода, playground с изменяемыми пропами
и проверку доступности. Второй такой инструмент рядом
означал бы второй источник правды и дублирование сотен демо — при одном
мейнтейнере это прямая дорога к расхождению, а расходятся такие пары молча.

То же относится к Chromatic и Histoire: своего не строится.

Свой Storybook у вас при этом остаётся осмысленным — но по другой причине. В нём
живут **ваши** компоненты, собранные из наших, и витрина нужна именно им.

## Что подключить

Granularity ничем не отличается от любой другой Vue-библиотеки, кроме одного:
её CSS не импортируется файлом, а собирается granum из манифеста пакета
([установка](/ru/docs/installation)). Поэтому вся настройка — про то, чтобы
plugin granum отработал внутри Storybook.

### Сборка

Storybook на Vite (`@storybook/vue3-vite`) читает ваш `vite.config.ts`. Если
плагин granum уже стоит там, добавлять в `.storybook/main.ts` нечего:

```ts title="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`:

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

```ts title=".storybook/preview.ts"
import '../src/reset.css'
import 'virtual:granum.css'
```

:::callout{type="warning"}
Сброс обязан лежать в слое, объявленном раньше слоёв `granum.*`, — иначе он
выигрывает у всей библиотеки, и истории отрисуются без рамок и с бесцветными
кнопками. Подробности — в [типовых поломках](/ru/docs/troubleshooting).
:::

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

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

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

```js title="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` это не зависит. Осталась ровно
половина проблемы, и она про ваш код, а не про чужой.

:::callout{type="info"}
Перечислять `components` в конфиге Storybook обычно не нужно: витрина по смыслу
показывает всё, а вес CSS в ней никого не волнует. Сужение списка — приём
боевого приложения, и там оно остаётся.
:::

## Обёртка историй

Дефолты поддерева и цель для оверлеев задаёт `GrConfigProvider`. В Storybook он
живёт в декораторе:

```ts title=".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 такие обёртки создают охотно.

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

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

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

```ts title="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
  подключаются вашими. Подробности — на странице
  [тестирования](/ru/docs/testing). `@feugene/granularity-test-kit` в основном
  про контрактные тесты пакетов семейства, но его `./a11y` (axe в jsdom) и
  `./e2e` (обход клавиатурой и axe в Playwright) пригодны и приложению.
- **Клавиатурные контракты** — описаны в репозитории библиотеки по компонентам,
  и история их не заменяет: они про поведение, а не про вид.
