返回博客

如何向 Storybook 添加主题切换器

了解如何通过连接装饰器和工具栏项目来控制故事的渲染方式

loading
Yann Braga
@yannbf
最近更新

主题控制用户界面的视觉特性——调色板、排版、留白、边框样式、阴影、半径等。主题越来越受欢迎,因为应用需要支持多种颜色模式和品牌要求。

但是主题开发可能很繁琐。您必须跟踪应用中无数的状态,然后将其乘以您支持的主题数量。同时还要不断地在主题之间来回切换,以检查用户界面是否看起来正确。

使用 Storybook,您可以控制哪个主题应用于您的组件,并通过工具栏单击以在不同主题之间切换。本文将向您展示如何操作。

  • 🎁 使用装饰器将主题对象传递给您的组件
  • 🎛 从工具栏或使用故事参数动态切换主题
  • 🖍 自动更新故事背景以匹配主题
  • 🍱 在一个故事中并排渲染多个主题

我们要构建什么?

与作为输入传递到组件的数据不同,主题通过上下文提供或全局配置为 CSS 变量。

我们将构建一个主题切换工具,使您能够在 Storybook 中为所有组件提供主题对象。您将能够通过参数或工具栏中的按钮来控制哪个主题处于活动状态。

2022-06-07 13.20.31.gif

我们将使用这个 Badge 组件(来自 Mealdrop 应用)进行演示,该组件使用 React 和 styled-components 构建。

它使用主题对象中的变量来设置边框半径、背景和颜色值。主题对象使用上下文 API 传递到组件中。

// src/components/Badge/Badge.tsx
import styled, { css } from 'styled-components'

import { Body } from '../typography'

const Container = styled.div(
  ({ theme }) => css`
    padding: 3px 8px;
    background: ${theme.color.badgeBackground};
    border-radius: ${theme.borderRadius.xs};
    display: inline-block;
    text-transform: capitalize;
    span {
      color: ${theme.color.badgeText};
    }
  `
)

type BadgeProps = {
  text: string
  className?: string
}

export const Badge = ({ text, className }: BadgeProps) => (
  <Container className={className}>
    <Body type="span" size="S">
      {text}
    </Body>
  </Container>
)

克隆仓库

让我们开始吧!克隆仓库,安装依赖项,并跟随操作。

# Clone the template
npx degit yannbf/mealdrop#theme-switcher-base mealdrop

cd mealdrop

# Install dependencies
yarn

使用装饰器为您的组件提供主题

第一步是为我们的组件提供主题。我们将使用装饰器来实现,它将使用 ThemeProvider 包裹每个故事并传入 lightTheme 对象。

装饰器是 Storybook 的一种机制,允许您使用额外的渲染功能来增强故事。例如,您可以提供组件所依赖的上下文或其他全局配置。

让我们将 withTheme 装饰器添加到 .storybook/preview.tsx 文件中。

// .storybook/preview.tsx
import { ThemeProvider } from 'styled-components'
import { DecoratorFn } from '@storybook/react'

import { GlobalStyle } from '../src/styles/GlobalStyle'
import { lightTheme } from '../src/styles/theme'

const withTheme: DecoratorFn = (StoryFn) => {
  return (
    <ThemeProvider theme={lightTheme}>
      <GlobalStyle />
      <StoryFn />
    </ThemeProvider>
  )
}

// export all decorators that should be globally applied in an array
export const decorators = [withTheme]

.storybook/preview.js|tsx 文件中定义的装饰器是全局的。也就是说,它们将应用于您的所有故事。因此,这也是加载这些组件使用的 GlobalStyle 的理想位置。

运行 yarn storybook 以启动 Storybook,您应该看到 Badge 组件在应用浅色主题的情况下正确渲染。

CleanShot 2022-06-07 at 16.37.42@2x.png

通过参数设置活动主题

目前,我们的 withTheme 装饰器仅为组件提供浅色主题。为了测试浅色和深色模式,我们需要在它们之间动态切换。我们可以使用 参数 来指定要启用的主题。

参数是可以附加到故事或组件的元数据。然后,withTheme 装饰器可以从 故事上下文 对象访问它们并应用适当的主题。

更新您的装饰器以读取主题参数

// .storybook/preview.tsx
import { ThemeProvider } from 'styled-components'
import { DecoratorFn } from '@storybook/react'

import { GlobalStyle } from '../src/styles/GlobalStyle'
import { darkTheme, lightTheme } from '../src/styles/theme'

const withTheme: DecoratorFn = (StoryFn, context) => {
  // Get the active theme value from the story parameter
  const { theme } = context.parameters
  const storyTheme = theme === 'dark' ? darkTheme : lightTheme
  return (
    <ThemeProvider theme={storyTheme}>
      <GlobalStyle />
      <StoryFn />
    </ThemeProvider>
  )
}

export const decorators = [withTheme]

在为组件编写故事时,您可以选择使用参数应用哪个主题。像这样

// src/components/Badge/Badge.stories.tsx
import { ComponentStory, ComponentMeta } from '@storybook/react'
import { Badge } from './Badge'

export default {
  title: 'Components/Badge',
  component: Badge,
} as ComponentMeta<typeof Badge>

const Template: ComponentStory<typeof Badge> = (args) => <Badge {...args} />

export const Default = Template.bind({})
Default.args = {
  text: 'Comfort food',
}

export const LightTheme = Template.bind({})
LightTheme.args = Default.args
LightTheme.parameters = {
  theme: 'light',
}

export const DarkTheme = Template.bind({})
DarkTheme.args = Default.args
DarkTheme.parameters = {
  theme: 'dark',
}

切换回您的 Storybook,您会注意到当您在两个故事之间导航时,主题会更新。

params.gif

太棒了!这使我们可以灵活地为每个故事设置主题。

切换背景颜色以匹配主题

这是一个好的开始。我们可以灵活地控制每个故事的主题。但是,背景保持不变。让我们更新我们的装饰器,以便故事的背景颜色与活动主题相匹配。

我们现在使用 ThemeBlock 组件包裹每个故事,该组件根据活动主题控制背景颜色。

// .storybook/preview.tsx
import React from 'react'
import styled, { css, ThemeProvider } from 'styled-components'
import { DecoratorFn } from '@storybook/react'

import { GlobalStyle } from '../src/styles/GlobalStyle'
import { darkTheme, lightTheme } from '../src/styles/theme'
import { breakpoints } from '../src/styles/breakpoints'

const ThemeBlock = styled.div<{ left?: boolean; fill?: boolean }>(
  ({ left, fill, theme }) =>
    css`
      position: absolute;
      top: 0;
      left: ${left || fill ? 0 : '50vw'};
      border-right: ${left ? '1px solid #202020' : 'none'};
      right: ${left ? '50vw' : 0};
      width: ${fill ? '100vw' : '50vw'};
      height: 100vh;
      bottom: 0;
      overflow: auto;
      padding: 1rem;
      background: ${theme.color.screenBackground};
      ${breakpoints.S} {
        left: ${left ? 0 : '50vw'};
        right: ${left ? '50vw' : 0};
        padding: 0 !important;
      }
    `
)

export const withTheme: DecoratorFn = (StoryFn, context) => {
  // Get values from story parameter first
  const { theme } = context.parameters
  const storyTheme = theme === 'dark' ? darkTheme : lightTheme
  return (
    <ThemeProvider theme={storyTheme}>
      <GlobalStyle />
      <ThemeBlock fill>
        <StoryFn />
      </ThemeBlock>
    </ThemeProvider>
  )
}

export const decorators = [withTheme]

现在,当您在这些故事之间切换时,主题和背景颜色都会更新。

params-and-bg.gif

从工具栏切换主题

通过参数硬编码主题只是一种选择。我们还可以自定义 Storybook UI 以添加一个下拉菜单,使我们能够切换哪个主题处于活动状态。

Storybook 附带 toolbars 插件,使您能够定义一个全局值并将其连接到工具栏中的菜单。

要创建一个 工具栏项目 来控制活动主题,我们需要在我们的 .storybook/preview.tsx 文件中添加一个 globalTypes 对象。

// .storybook/preview.tsx

// ...code ommited for brevity...

export const withTheme: DecoratorFn = (StoryFn, context) => {
  // Get values from story parameter first, else fallback to globals
  const theme = context.parameters.theme || context.globals.theme
  const storyTheme = theme === 'dark' ? darkTheme : lightTheme
  return (
    <ThemeProvider theme={storyTheme}>
      <GlobalStyle />
      <ThemeBlock fill>
        <StoryFn />
      </ThemeBlock>
    </ThemeProvider>
  )
}

export const globalTypes = {
  theme: {
    name: 'Theme',
    description: 'Global theme for components',
    defaultValue: 'light',
    toolbar: {
      // The icon for the toolbar item
      icon: 'circlehollow',
      // Array of options
      items: [
        { value: 'light', icon: 'circlehollow', title: 'light' },
        { value: 'dark', icon: 'circle', title: 'dark' },
      ],
      // Property that specifies if the name of the item will be displayed
      showName: true,
    },
  },
}

export const decorators = [withTheme]

我们还更新了 withTheme 装饰器,以首先从参数中获取主题值,如果未定义,则回退到全局值。

您现在应该看到一个用于切换主题的工具栏项目。

toolbar.png

对于未指定主题参数的 Default 故事,您可以使用工具栏切换主题。但是,LightThemeDarkTheme 故事将始终强制执行通过主题参数设置的值。

并排渲染主题

有时,如果您一次看到组件的所有主题变体,则更容易处理组件。猜猜怎么着?您可以在装饰器中多次渲染一个故事,并为每个实例提供不同的主题对象。

更新 withTheme 装饰器和 globalTypes 以添加“并排”模式

// .storybook/preview.tsx

// ...code ommited for brevity...

export const withTheme: DecoratorFn = (StoryFn, context) => {
  // Get values from story parameter first, else fallback to globals
  const theme = context.parameters.theme || context.globals.theme
  const storyTheme = theme === 'light' ? lightTheme : darkTheme

  switch (theme) {
    case 'side-by-side': {
      return (
        <>
          <ThemeProvider theme={lightTheme}>
            <GlobalStyle />
            <ThemeBlock left>
              <StoryFn />
            </ThemeBlock>
          </ThemeProvider>
          <ThemeProvider theme={darkTheme}>
            <GlobalStyle />
            <ThemeBlock>
              <StoryFn />
            </ThemeBlock>
          </ThemeProvider>
        </>
      )
    }
    default: {
      return (
        <ThemeProvider theme={storyTheme}>
          <GlobalStyle />
          <ThemeBlock fill>
            <StoryFn />
          </ThemeBlock>
        </ThemeProvider>
      )
    }
  }
}

export const globalTypes = {
  theme: {
    name: 'Theme',
    description: 'Theme for the components',
    defaultValue: 'light',
    toolbar: {
      // The icon for the toolbar item
      icon: 'circlehollow',
      // Array of options
      items: [
        { value: 'light', icon: 'circlehollow', title: 'light' },
        { value: 'dark', icon: 'circle', title: 'dark' },
        { value: 'side-by-side', icon: 'sidebar', title: 'side by side' },
      ],
      // Property that specifies if the name of the item will be displayed
      showName: true,
    },
  },
}

export const decorators = [withTheme]

这是最终结果

side-by-side.gif

并排模式对于使用 Chromatic 等工具进行视觉回归测试也非常方便。您可以通过参数启用它,并在一次测试中测试组件的所有基于主题的变体。

结论

在构建用户界面时,您必须考虑应用状态、区域设置、视口大小、主题等无数的排列组合。Storybook 使测试 UI 变体变得容易。您可以使用数百个 插件 之一,或自定义 Storybook 以满足您的需求。

装饰器 使您可以完全控制故事渲染,并使您能够设置提供程序并通过使用参数或将它们连接到工具栏项目来控制其行为。切换主题只是这项技术的一种应用。您可以使用它来添加语言切换器或菜单来管理多租户配置。

您可以在 Mealdrop Storybook 中看到主题切换器的实际效果,并在 Github 上浏览其源代码。

本教程最初是作为我的课程 Storybook for React Apps 的一个章节编写的。它涵盖了从核心 Storybook 概念到更高级的工作流程的所有内容,例如将页面添加到 Storybook、模拟 API 请求、将故事连接到 Figma、测试可访问性等等。

加入 Storybook 邮件列表

获取最新的新闻、更新和发布

6,730位开发者,持续增加中

我们正在招聘!

加入 Storybook 和 Chromatic 背后的团队。构建被成千上万开发者在生产环境中使用的工具。远程优先。

查看职位

热门文章

社区案例展示 #2

VSCode 扩展。变体、Recoil 和代码编辑器插件。以及大量新的学习资源。
loading
João Cardoso

如何在 Storybook 中构建连接组件

了解如何使用装饰器模拟上下文、应用状态和 API 请求
loading
Varun Vachhar

组件百科全书

探索世界上的 UI 组件,学习真正有效的技术
loading
Dominic Nguyen
加入社区
6,730位开发者,持续增加中
为什么为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
案例展示探索项目组件术语表
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI