返回Storybook 简介
章节
  • 开始上手
  • 简单的组件
  • 复合组件
  • 数据
  • 页面
  • 部署
  • 视觉测试
  • 可访问性测试
  • 结论
  • 贡献

构建一个复合组件

从更简单的组件组合成复合组件

上一章,我们构建了第一个组件;本章我们将扩展所学内容,创建任务列表(TaskList),一个任务(Task)的列表。让我们将组件组合在一起,看看当引入更多复杂性时会发生什么。

任务列表

Taskbox 通过将置顶任务放在默认任务之上来强调它们。它产生了 TaskList 的两种变体,你需要为它们创建故事:默认和置顶项。

default and pinned tasks

由于 Task 数据可以异步发送,我们需要一个在没有连接时渲染的加载状态。此外,当没有任务时,我们需要一个空状态。

empty and loading tasks

准备就绪

复合组件与其所包含的基本组件在本质上差别不大。创建一个 TaskList 组件和一个配套的故事文件:src/components/TaskList.vuesrc/components/TaskList.stories.ts

TaskList 的粗略实现开始。您需要导入之前的 Task 组件,并将其属性作为输入传递。

复制
src/components/TaskList.vue
<template>
  <div class="list-items">
    <template v-if="loading"> loading </template>
    <template v-else-if="isEmpty"> empty </template>
    <template v-else>
      <Task
        v-for="task in tasks"
        :key="task.id"
        :task="task"
        @archive-task="onArchiveTask"
        @pin-task="onPinTask"
      />
    </template>
  </div>
</template>
<script lang="ts" setup>
import type { TaskData } from '../types'

import { computed } from 'vue'

import Task from './Task.vue'

type TaskListProps = {
  tasks: TaskData[]
  loading?: boolean
}

const props = defineProps<TaskListProps>()

const isEmpty = computed(() => props.tasks.length === 0)

const emit = defineEmits<{
  (e: 'archive-task', id: string): void
  (e: 'pin-task', id: string): void
}>()

/**
 * Event handler for archiving tasks
 */
function onArchiveTask(taskId: string): void {
  emit('archive-task', taskId)
}

/**
 * Event handler for pinning tasks
 */
function onPinTask(taskId: string): void {
  emit('pin-task', taskId)
}
</script>

接下来,在故事文件中创建 Tasklist 的测试状态。

复制
src/components/TaskList.stories.ts
import type { Meta, StoryObj } from '@storybook/vue3-vite'

import TaskList from './TaskList.vue'

import * as TaskStories from './Task.stories'

export const TaskListData = [
  { ...TaskStories.TaskData, id: '1', title: 'Task 1' },
  { ...TaskStories.TaskData, id: '2', title: 'Task 2' },
  { ...TaskStories.TaskData, id: '3', title: 'Task 3' },
  { ...TaskStories.TaskData, id: '4', title: 'Task 4' },
  { ...TaskStories.TaskData, id: '5', title: 'Task 5' },
  { ...TaskStories.TaskData, id: '6', title: 'Task 6' },
]

const meta = {
  component: TaskList,
  title: 'TaskList',
  tags: ['autodocs'],
  excludeStories: /.*Data$/,
  decorators: [() => ({ template: '<div style="margin: 3em;"><story/></div>' })],
  args: {
    ...TaskStories.TaskData.events,
  },
} satisfies Meta<typeof TaskList>

export default meta
type Story = StoryObj<typeof meta>

export const Default: Story = {
  args: {
    // Shaping the stories through args composition.
    // Inherited data coming from the Default story.
    tasks: TaskListData,
  },
}

export const WithPinnedTasks: Story = {
  args: {
    // Shaping the stories through args composition.
    // Inherited data coming from the Default story.
    tasks: [
      ...Default.args.tasks.slice(0, 5),
      { id: '6', title: 'Task 6 (pinned)', state: 'TASK_PINNED' },
    ],
  },
}

export const Loading: Story = {
  args: {
    tasks: [],
    loading: true,
  },
}

export const Empty: Story = {
  args: {
    // Shaping the stories through args composition.
    // Inherited data coming from the Loading story.
    ...Loading.args,
    loading: false,
  },
}

💡装饰器 是一种为故事提供任意包装器的方法。在这种情况下,我们在默认导出上使用装饰器键来在渲染的组件周围添加样式。但它们也可以为组件添加其他上下文,我们稍后会看到。

通过导入 TaskStories,我们能够以最小的努力 组合 我们故事中的参数(简称 args)。这样,两个组件都期望的数据和操作(模拟的回调)都能得到保留。

现在在 Storybook 中检查新的 TaskList 故事。

构建状态

我们的组件仍然很粗糙,但现在我们对要努力实现的故事有了一些想法。你可能会认为 .list-items 包装器过于简单。你是对的——在大多数情况下,我们不会创建一个新组件只是为了添加一个包装器。但是 TaskList 组件的真正复杂性体现在边缘情况 withPinnedTasksloadingempty 中。

复制
src/components/TaskList.vue
<template>
  <div class="list-items">
    <template v-if="loading">
      <div v-for="n in 6" :key="n" class="loading-item" data-testid="loading" id="loading">
        <span class="glow-checkbox" />
        <span class="glow-text"> <span>Loading</span> <span>cool</span> <span>state</span> </span>
      </div>
    </template>

    <div v-else-if="isEmpty" class="list-items" data-testid="empty" id="empty">
      <div class="wrapper-message">
        <span class="icon-check" />
        <p class="title-message">You have no tasks</p>
        <p class="subtitle-message">Sit back and relax</p>
      </div>
    </div>

    <template v-else>
      <Task
        v-for="task in tasksInOrder"
        :key="task.id"
        :task="task"
        @archive-task="onArchiveTask"
        @pin-task="onPinTask"
      />
    </template>
  </div>
</template>
<script lang="ts" setup>
import type { TaskData } from '../types'

import { computed } from 'vue'

import Task from './Task.vue'

type TaskListProps = {
  tasks: TaskData[]
  loading?: boolean
}

const props = defineProps<TaskListProps>()

const isEmpty = computed(() => props.tasks.length === 0)
const tasksInOrder = computed(() => {
  return [
    ...props.tasks.filter((t) => t.state === 'TASK_PINNED'),
    ...props.tasks.filter((t) => t.state !== 'TASK_PINNED'),
  ]
})

const emit = defineEmits<{
  (e: 'archive-task', id: string): void
  (e: 'pin-task', id: string): void
}>()

/**
 * Event handler for archiving tasks
 */
function onArchiveTask(taskId: string): void {
  emit('archive-task', taskId)
}

/**
 * Event handler for pinning tasks
 */
function onPinTask(taskId: string): void {
  emit('pin-task', taskId)
}
</script>

添加的标记会产生以下 UI

注意置顶项在列表中的位置。我们希望置顶项渲染在列表的顶部,以优先显示给用户。

💡 别忘了使用 git 提交你的更改!
保持代码与本章节同步。在 GitHub 上查看 3221bbb。
这个免费指南对您有帮助吗?发推文表示感谢,并帮助其他开发者找到它。
下一章
数据
了解如何将数据集成到你的 UI 组件中
✍️ 在 GitHub 上编辑 – PR 欢迎!
加入社区
7,424开发者及更多
原因为什么选择 Storybook组件驱动的 UI
文档指南教程更新日志遥测
社区插件参与进来博客
展示探索关于
开源软件
Storybook - Storybook 中文

特别感谢 Netlify CircleCI