构建一个复合组件
上一章,我们构建了第一个组件;本章我们将扩展所学内容,创建任务列表(TaskList),一个任务(Task)的列表。让我们将组件组合在一起,看看当引入更多复杂性时会发生什么。
任务列表
Taskbox 通过将置顶任务放在默认任务之上来强调它们。它产生了 TaskList 的两种变体,你需要为它们创建故事:默认和置顶项。

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

准备就绪
复合组件与它所包含的基本组件并没有太大区别。创建一个TaskList组件和一个配套的故事文件:src/app/components/task-list.component.ts和src/app/components/task-list.stories.ts。
从 TaskList 的粗略实现开始。你需要导入之前的 Task 组件,并将属性和操作作为输入传递。
import type { TaskData } from '../types';
import { CommonModule } from '@angular/common';
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { TaskComponent } from './task.component';
@Component({
selector: 'app-task-list',
standalone: true,
imports: [CommonModule, TaskComponent],
template: `
<div class="list-items">
<div *ngIf="loading">loading</div>
<div *ngIf="!loading && tasks.length === 0">empty</div>
<app-task
*ngFor="let task of tasks"
[task]="task"
(onArchiveTask)="onArchiveTask.emit($event)"
(onPinTask)="onPinTask.emit($event)"
>
</app-task>
</div>
`,
})
export class TaskListComponent {
/**
* The list of tasks
*/
@Input() tasks: TaskData[] = [];
/**
* Checks if it's in loading state
*/
@Input() loading = false;
/**
* Event to change the task to pinned
*/
@Output()
onPinTask = new EventEmitter<Event>();
/**
* Event to change the task to archived
*/
@Output()
onArchiveTask = new EventEmitter<Event>();
}
接下来,在故事文件中创建 Tasklist 的测试状态。
import type { Meta, StoryObj } from '@storybook/angular';
import { componentWrapperDecorator } from '@storybook/angular';
import { TaskListComponent } from './task-list.component';
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: Meta<TaskListComponent> = {
component: TaskListComponent,
title: 'TaskList',
tags: ['autodocs'],
excludeStories: /.*Data$/,
decorators: [
//👇 Wraps our stories with a decorator
componentWrapperDecorator(
(story) => `<div style="margin: 3em">${story}</div>`
),
],
args: {
...TaskStories.TaskData.events,
},
};
export default meta;
type Story = StoryObj<TaskListComponent>;
export const Default: Story = {
args: {
tasks: TaskListData,
},
}
export const WithPinnedTasks: Story = {
args: {
// Shaping the stories through args composition.
// Inherited data coming from the Default story.
tasks: [
// Shaping the stories through args composition.
// Inherited data coming from the Default story.
...(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 组件的真正复杂性体现在边缘情况 withPinnedTasks、loading 和 empty 中。
import type { TaskData } from '../types';
import { CommonModule } from '@angular/common';
import { Component, Input, Output, EventEmitter } from '@angular/core';
import { TaskComponent } from './task.component';
@Component({
selector: 'app-task-list',
standalone: true,
imports: [CommonModule, TaskComponent],
template: `
<div class="list-items">
<app-task
*ngFor="let task of tasksInOrder"
[task]="task"
(onArchiveTask)="onArchiveTask.emit($event)"
(onPinTask)="onPinTask.emit($event)"
>
</app-task>
<div
*ngIf="tasksInOrder.length === 0 && !loading"
class="wrapper-message"
data-testid="empty"
>
<span class="icon-check"></span>
<p class="title-message">You have no tasks</p>
<p class="subtitle-message">Sit back and relax</p>
</div>
<div *ngIf="loading">
<div *ngFor="let i of [1, 2, 3, 4, 5, 6]" class="loading-item">
<span class="glow-checkbox"></span>
<span class="glow-text"> <span>Loading</span> <span>cool</span> <span>state</span> </span>
</div>
</div>
</div>
`,
})
export class TaskListComponent {
/**
* @ignore
* Component property to define ordering of tasks
*/
tasksInOrder: TaskData[] = [];
/**
* Checks if it's in loading state
*/
@Input() loading = false;
/**
* Event to change the task to pinned
*/
@Output()
onPinTask = new EventEmitter<Event>();
/**
* Event to change the task to archived
*/
@Output()
onArchiveTask = new EventEmitter<Event>();
/**
* The list of tasks
*/
@Input()
set tasks(arr: TaskData[]) {
const initialTasks = [
...arr.filter((t) => t.state === 'TASK_PINNED'),
...arr.filter((t) => t.state !== 'TASK_PINNED'),
];
const filteredTasks = initialTasks.filter(
(t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
);
this.tasksInOrder = filteredTasks.filter(
(t) => t.state === 'TASK_INBOX' || t.state === 'TASK_PINNED'
);
}
}
添加的标记会产生以下 UI
注意置顶项在列表中的位置。我们希望置顶项渲染在列表的顶部,以优先显示给用户。