组装一个复合组件
上一章节,我们构建了我们的第一个组件;本章将扩展我们所学的知识,制作 TaskList,一个任务列表。让我们将组件组合在一起,看看当我们引入更多复杂性时会发生什么。
任务列表
Taskbox 通过将置顶任务放在默认任务之上来突出显示它们。它产生了你需要为其创建故事的 TaskList
的两个变体:默认项和置顶项。
由于 Task
数据可以异步发送,我们也需要一个加载状态,以便在没有连接时进行渲染。此外,当没有任务时,我们需要一个空状态。
准备设置
复合组件与它包含的基本组件没有太大区别。创建一个 TaskList
组件和一个配套的故事文件:src/components/TaskList.tsx
和 src/components/TaskList.stories.tsx
。
从 TaskList
的粗略实现开始。你需要从前面导入 Task
组件,并将属性和动作作为输入传入。
import type { TaskData } from '../types';
import Task from './Task';
type TaskListProps = {
/** Checks if it's in loading state */
loading?: boolean;
/** The list of tasks */
tasks: TaskData[];
/** Event to change the task to pinned */
onPinTask: (id: string) => void;
/** Event to change the task to archived */
onArchiveTask: (id: string) => void;
};
export default function TaskList({
loading = false,
tasks,
onPinTask,
onArchiveTask,
}: TaskListProps) {
const events = {
onPinTask,
onArchiveTask,
};
if (loading) {
return <div className="list-items">loading</div>;
}
if (tasks.length === 0) {
return <div className="list-items">empty</div>;
}
return (
<div className="list-items">
{tasks.map((task) => (
<Task key={task.id} task={task} {...events} />
))}
</div>
);
}
接下来,在故事文件中创建 Tasklist
的测试状态。
import type { Meta, StoryObj } from '@storybook/react';
import TaskList from './TaskList';
import * as TaskStories from './Task.stories';
const meta = {
component: TaskList,
title: 'TaskList',
decorators: [(story) => <div style={{ margin: '3rem' }}>{story()}</div>],
tags: ["autodocs"],
args: {
...TaskStories.ActionsData,
},
} satisfies Meta<typeof TaskList>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
// Shaping the stories through args composition.
// The data was inherited from the Default story in Task.stories.tsx.
tasks: [
{ ...TaskStories.Default.args.task, id: '1', title: 'Task 1' },
{ ...TaskStories.Default.args.task, id: '2', title: 'Task 2' },
{ ...TaskStories.Default.args.task, id: '3', title: 'Task 3' },
{ ...TaskStories.Default.args.task, id: '4', title: 'Task 4' },
{ ...TaskStories.Default.args.task, id: '5', title: 'Task 5' },
{ ...TaskStories.Default.args.task, id: '6', title: 'Task 6' },
],
},
};
export const WithPinnedTasks: Story = {
args: {
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,
},
};
💡 装饰器 是一种为故事提供任意包装器的方法。在本例中,我们在默认导出上使用装饰器键来添加一些围绕渲染组件的 margin
。它们也可以用于将故事包装在“提供器”中——即,设置 React 上下文的库组件。
通过导入 TaskStories
,我们能够以最小的努力组合故事中的参数(简称 args)。这样,两个组件期望的数据和动作(模拟回调)都得以保留。
现在检查 Storybook 中新的 TaskList
故事。
构建状态
我们的组件仍然很粗糙,但现在我们对要实现的故事有了一个概念。你可能会认为 .list-items
包装器过于简单。你是对的——在大多数情况下,我们不会仅仅为了添加一个包装器而创建一个新组件。但是 TaskList
组件的真正复杂性在边缘情况 withPinnedTasks
、loading
和 empty
中揭示出来。
import type { TaskData } from '../types';
import Task from './Task';
type TaskListProps = {
/** Checks if it's in loading state */
loading?: boolean;
/** The list of tasks */
tasks: TaskData[];
/** Event to change the task to pinned */
onPinTask: (id: string) => void;
/** Event to change the task to archived */
onArchiveTask: (id: string) => void;
};
export default function TaskList({
loading = false,
tasks,
onPinTask,
onArchiveTask,
}: TaskListProps) {
const events = {
onPinTask,
onArchiveTask,
};
const LoadingRow = (
<div className="loading-item">
<span className="glow-checkbox" />
<span className="glow-text">
<span>Loading</span> <span>cool</span> <span>state</span>
</span>
</div>
);
if (loading) {
return (
<div className="list-items" data-testid="loading" key={"loading"}>
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
{LoadingRow}
</div>
);
}
if (tasks.length === 0) {
return (
<div className="list-items" key={"empty"} data-testid="empty">
<div className="wrapper-message">
<span className="icon-check" />
<p className="title-message">You have no tasks</p>
<p className="subtitle-message">Sit back and relax</p>
</div>
</div>
);
}
const tasksInOrder = [
...tasks.filter((t) => t.state === "TASK_PINNED"),
...tasks.filter((t) => t.state !== "TASK_PINNED"),
];
return (
<div className="list-items">
{tasksInOrder.map((task) => (
<Task key={task.id} task={task} {...events} />
))}
</div>
);
}
添加的标记会产生以下 UI
请注意列表中置顶项的位置。我们希望置顶项在列表顶部渲染,使其成为我们用户的优先事项。