2. Storybook

Storybook

Storybook — это инструмент для разработки и документирования UI-компонентов. Он создаёт изолированную среду, где можно просматривать, тестировать и изменять интерфейсные компоненты вне зависимости от основного приложения. Storybook поддерживает различные библиотеки, такие как React, Vue, Angular и другие, и может быть полезен для команд разработки, дизайнеров и тестировщиков.

Основные функции Storybook

  1. Изоляция компонентов: компоненты отображаются в изолированном окружении, что позволяет избежать влияния других элементов приложения. Это удобно для разработки и отладки компонентов.

  2. Документация: каждый компонент может сопровождаться документированными историями, что облегчает его понимание, использование и поддержку другими членами команды. Storybook создаёт живую документацию, где можно увидеть компоненты в действии с разными параметрами (props).

  3. Тестирование интерфейсов: Storybook легко интегрируется с инструментами для визуального тестирования, такими как Chromatic, что позволяет отслеживать изменения в интерфейсе и предотвращать визуальные баги.

  4. Поддержка аддонов: Storybook поддерживает различные аддоны, которые позволяют добавлять функциональность (например, контроль доступности, тестирование взаимодействия и локализации).

  5. Консистентность интерфейса: разработчики и дизайнеры могут просматривать все компоненты в одном месте, что помогает соблюдать единый стиль и стандарты в проекте.

В общем, Storybook упрощает процесс разработки интерфейсов, позволяет быстро демонстрировать UI-компоненты, а также служит интерактивной документацией для команды.

🔗

Примеры для вдохновления:

Stories

Устанавливаем и стартуем проект согласно документации

Terminal
pnpm dlx storybook@latest init

Результат. Запустим storybook и посмотрим визуально, что у мы имеем из коробки 🚀

Button

Пробежавшись по коду и визуально поняв как это выглядит давайте напишем свою первую историю для компонента Button.tsx

Заглушка

Вот это стандартная заглушка, которую вы будете использовать для всех историй. Только менять Button на нужный компонент

src/components/Button/Button.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
 
import { Button } from './Button'
 
const meta = {
  component: Button,
} satisfies Meta<typeof Button>
 
export default meta
type Story = StoryObj<typeof meta>

Satisfies

Оператор satisfies — это новая конструкция, добавленная в версии 4.9, которая помогает указать, что объект удовлетворяет определённому типу. Она обеспечивает более точное соответствие между объектом и типом, чем простая аннотация типов, и может выявлять лишние или несовместимые поля.

Первая история

Мы просто описываем вариант компоненты, который хотим увидеть

Button.stories.tsx
export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
}

Получаем следующий результат

primary button 1

История отобразилась, но стилей нет 🥲

Для того чтобы в storybook применялись стили их необходимо добавить в .storybook/preview.ts

preview.ts
import type { Preview } from '@storybook/react'
import '../src/index.css'
/*...*/

primary button 2

Результат. Первая история готова 🚀

Как писать истории

Историй описывающих компонент может быть сколько угодно. В истории описываются каким может быть компонент, различные его состояния, чтобы человек который смотрит сторибук сразу понимал что умеет ваш компонент и как его применять

Написание историй вручную

Просто копируем первую историю, меняем variant и получаем еще одну историю 🚀

Button.stories.tsx
export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: 'Secondary Button',
  },
}
 
export const LinkAsButton: Story = {
  args: {
    children: <a href="/sign-up">Sign Up</a>,
    asChild: true,
  },
}

Написание историй в addons панели

💡

Если по умолчанию не открыта addons панель нажмите ALT + A

Меняем в панель состояние кнопки, нажимаем кнопку Crete new story

outlined button

Даем название для истории

outlined button 2

И история создастся автоматически (откройте Button.stories.tsx и убедитесь в этом) 🚀

Документация

Для того чтобы автоматически сгенерировать документацию необходимо добавить tags: ['autodocs'] в meta

Button.stories.tsx
const meta = {
  component: Button,
  tags: ['autodocs'],
} satisfies Meta<typeof Button>

Результат. Получаем красивую документацию, описывающую различные варианты компонента 🚀

auto docs

Расширенная документация

Storybook предоставляет множество решений для написаний документации. Чтобы автоматически генерировать документации, намо просто описать при помощи JSDoc комментариев.

Давайте подсмотрим как сделана документация на примере кнопки, которую автоматически создал сторибук при его установке.

stories/Button.tsx
export interface ButtonProps {
  /** Is this the principal call to action on the page? */
  primary?: boolean
  /** What background color to use */
  backgroundColor?: string
  /** How large should the button be? */
  size?: 'small' | 'medium' | 'large'
  /** Button contents */
  label: string
  /** Optional click handler */
  onClick?: () => void
}
 
/** Primary UI component for user interaction */
export const Button = () => {
  /*...*/
}

Сделаем по аналогии для нашей кнопки

Button.tsx
type Props = {
  /** Choose from 3 style variants. Default: 'primary'. */
  variant?: 'primary' | 'secondary' | 'outlined'
  /** Render the Button using any element if asChild true */
  asChild?: boolean
} & ComponentPropsWithoutRef<'button'>
 
/** Ui kit Button component */
export const Button = ({ variant = 'primary', className, asChild, ...rest }: Props) => {
  const Component = asChild ? Slot : 'button'
 
  return <Component className={clsx(s.button, s[variant], className)} {...rest} />
}
Button.stories.tsx
/** Primary variant. Used as 'default'*/
export const Primary: Story = {
  args: {
    variant: 'primary',
    children: 'Primary Button',
  },
}
 
/** Secondary variant*/
export const Secondary: Story = {
  args: {
    variant: 'secondary',
    children: 'Secondary Button',
  },
}
 
/** Link as Button variant*/
export const LinkAsButton: Story = {
  args: {
    children: <a href="/sign-up">Sign Up</a>,
    asChild: true,
  },
}
 
/** Outlined variant*/
export const Outlined: Story = {
  args: {
    variant: 'outlined',
    children: 'Outlined Button',
  },
}

Результат. Документация готова 🚀

documentation

Реализуем по аналогии ModalRadix.stories.tsx

ModalRadix.stories.tsx
import type { Meta, StoryObj } from '@storybook/react'
 
import { ModalRadix } from './ModalRadix'
 
const meta = {
  component: ModalRadix,
  tags: ['autodocs'],
} satisfies Meta<typeof ModalRadix>
 
export default meta
type Story = StoryObj<typeof meta>
 
export const BaseModal: Story = {
  args: {
    open: true,
    onClose: () => console.log('modal close'),
    modalTitle: 'Modal title',
    children: (
      <div>
        Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ab at cumque distinctio dolorem
        doloremque impedit incidunt, inventore iure officia omnis placeat possimus praesentium,
        quisquam repellendus repudiandae saepe sunt ullam veniam.
      </div>
    ),
  },
}
ModalRadix.tsx
type ModalSize = 'lg' | 'md' | 'sm'
 
type Props = {
  /** The controlled open state of the Modal */
  open: boolean
  /** Close modal handler */
  onClose: () => void
  /** Modal title */
  modalTitle: string
  /** 'sm' | 'md' | 'lg':
   * sm - 367px,
   * md - 532px,
   * lg - 764px.
   * Default: 'md'
   * For other values use className */
  size?: ModalSize
} & ComponentPropsWithoutRef<'div'>
 
/** Ui kit ModalRadix component */
export const ModalRadix = ({
  size = 'md',
  open,
  modalTitle,
  onClose,
  children,
  className,
  ...rest
}: Props) => {
  /*...*/
}

Результат. Базовая история для модалки реализована 🚀

base modal

Working with React Hooks

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

React Hooks — это удобные вспомогательные методы для создания компонентов с более упрощённым подходом. Вы можете использовать их при создании историй для вашего компонента, если это необходимо, хотя стоит рассматривать их как продвинутый случай использования. Мы рекомендуем использовать аргументы по возможности при написании собственных историй

ModalRadix.stories.tsx
import { Meta, StoryObj } from "@storybook/react"
import { useState } from "react"
import { Button } from "../Button/Button.tsx"
import { ModalRadix } from "./ModalRadix.tsx"
 
const meta = {
  component: ModalRadix,
  tags: ["autodocs"],
} satisfies Meta<typeof ModalRadix>
 
export default meta
type Story = StoryObj<typeof ModalRadix>
 
/** BaseModal */
export const BaseModal: Story = {
  args: {
    modalTitle: "Modal title",
    children: <div>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Cupiditate, error.</div>,
  },
  render: (args) => {
    const [showModal, setShowModal] = useState(false)
 
    const openModalHandler = () => {
      setShowModal(true)
    }
 
    const closeModalHandler = () => {
      setShowModal(false)
    }
 
    return (
      <>
        <Button variant={"primary"} onClick={openModalHandler}>
          Open modal
        </Button>
        <ModalRadix {...args} open={showModal} onClose={closeModalHandler} />
      </>
    )
  },
}
 
💡

Если будет eslint ошибка, то устранить ее можно следующим образом

Результат. Теперь все работает и ошибок нету 🚀

modal with useState

Small modal

Реализуем новую историю для модального окна при котором измени размер модального окна (size='sm')

ModalRadix.stories.tsx
/** SmallModal */
export const SmallModal: Story = {
  args: {
    ...BaseModal.args,
    size: "sm",
  },
  render: (args) => {
    const [showModal, setShowModal] = useState(false)
 
    const openModalHandler = () => {
      setShowModal(true)
    }
 
    const closeModalHandler = () => {
      setShowModal(false)
    }
 
    return (
      <>
        <Button variant={"primary"} onClick={openModalHandler}>
          Open modal
        </Button>
        <ModalRadix {...args} open={showModal} onClose={closeModalHandler} />
      </>
    )
  },
}

Результат. История работает, но дублирование кода на лицо

Избавляемся от дублирования

Для того, чтобы не дублировать код вынесем код в отдельный компонент, назовем его Render (можно как угодно) и передадим его в свойство render.

💡

Для типизации args:

  • переименуем и Props на ModalRadixProps в компоненте ModalRadix.tsx

  • экспортируем ModalRadixProps

ModalRadix.tsx
export type ModalRadixProps = {
/*...*/
} & ComponentPropsWithoutRef<"div">
ModalRadix.stories.tsx
function Render(args: ModalRadixProps) {
  const [showModal, setShowModal] = useState(false)
 
  const openModalHandler = () => {
    setShowModal(true)
  }
 
  const closeModalHandler = () => {
    setShowModal(false)
  }
 
  return (
    <>
      <Button variant={"primary"} onClick={openModalHandler}>
        Open modal
      </Button>
      <ModalRadix {...args} open={showModal} onClose={closeModalHandler} />
    </>
  )
}
 
/** BaseModal */
export const BaseModal: Story = {
  args: {
    modalTitle: "Modal title",
    children: <div>Lorem ipsum dolor sit amet, consectetur adipisicing elit. Cupiditate, error.</div>,
  },
  render: Render,
}
 
/** SmallModal */
export const SmallModal: Story = {
  args: {
    ...BaseModal.args,
    size: "sm",
  },
  render: Render,
}

Результат. Истории отрабатывают как и прежде. Избавились от дублирования кода 🚀