第十四章 UI 依赖构建:Collapsible 与 Tooltip

第十四章 UI 依赖构建:CollapsibleTooltip

在第十三章,我们成功打通了从 API 到 useNavData Hook 的完整数据流。现在,我们的数据已经万事俱备,只欠渲染它的 UI 组件。

NavVertical (我们的最终目标) 依赖两个核心的 src/ui 原子组件来实现其交互:

  1. Collapsible:用于 NavGroupNavList 的展开与折叠。
  2. Tooltip:用于 NavItemcaption(小字提示)的悬浮显示。

本章的任务,就是遵循我们在第八章(Button)和第九章(Input/Label)建立的 CDD(组件驱动开发) 流程,将这两个“积木”添加到我们的 UI 库中。


14.1 任务:CDD 构建 (一) - Collapsible

Collapsible(可折叠面板)是 NavListNavGroup 能够“展开”和“收起”的 功能基石。我们将严格按照 CDD 范式 (add -> Story -> Refactor -> Docs) 来构建它。

14.1.1. (Add) 添加组件基础

我们首先使用 shadcn/ui 的 CLI 命令来添加组件的“骨架”。

1
pnpm dlx shadcn@latest add collapsible

执行此命令后,shadcn 会为我们创建 src/components/ui/collapsible.tsx 文件。正如我们在第九章所配置的,它默认只会为我们生成一个 纯粹的、无样式的 Radix UI 重导出

文件路径: src/components/ui/collapsible.tsx (初始)

1
2
3
4
5
6
7
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible"

const Collapsible = CollapsiblePrimitive.Root
const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger
const CollapsibleContent = CollapsiblePrimitive.CollapsibleContent

export { Collapsible, CollapsibleTrigger, CollapsibleContent }

架构思考:这种“最小化”的初始文件是 shadcn/ui 理念的完美体现。它 只提供功能(来自 @radix-ui/react-collapsible),而将 所有样式(className)的决定权 交还给我们。我们接下来的任务,就是为这个“骨架”穿上“皮肤”。

14.1.2. (Story) Storybook 先行

在修改组件源码之前,我们必须先在 Storybook 中“看见”它。我们需要一个可视化的“画布”来验证我们后续的样式修改。

我们将创建 collapsible.stories.tsx 文件,并编写一个基础的 Default 故事。

创建文件

1
touch src/components/ui/collapsible/collapsible.stories.tsx

编写故事

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
// src/components/ui/collapsible/collapsible.stories.tsx

import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from '../button/button'; // 导入我们之前构建的 Button
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from './collapsible';

const meta: Meta<typeof Collapsible> = {
title: 'UI/Collapsible',
component: Collapsible,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
open: {
control: 'boolean',
description: '受控:是否展开',
},
disabled: {
control: 'boolean',
description: '是否禁用',
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {
// 默认不设置 open,让组件处于“非受控”状态
},
render: (args) => (
<Collapsible {...args} className="w-[350px] space-y-2">
<CollapsibleTrigger asChild>
{/* 我们使用 Button 作为触发器 */}
<Button variant="outline">Toggle Content</Button>
</CollapsibleTrigger>

{/* [关键] 此时,我们的 CollapsibleContent 还没有样式。
我们手动添加一些 Tailwind 类,以便能在 Storybook 中“看见”它。
*/}
<CollapsibleContent>
<div className="rounded-md border px-4 py-3 font-mono text-sm shadow-sm">
@radix-ui/colors
</div>
<div className="rounded-md border px-4 py-3 font-mono text-sm shadow-sm mt-2">
@radix-ui/icons
</div>
</CollapsibleContent>
</Collapsible>
),
};

验证(“RED”):现在运行 pnpm storybook。你会看到一个 Collapsible 组件,但当你点击按钮时,内容的出现和消失是 瞬时 的、没有动画 的。

这就是我们的“RED”状态。我们的目标(“GREEN”)是让它拥有平滑展开/折叠动画。

14.1.3. 编码实现:样式抽离与动画注入

遵循 Button 组件的架构模式,我们需要将样式抽离到独立的 variants 文件中。我们的 index.css 已经通过 @plugin "tailwindcss-animate" 引入了 collapsible-downcollapsible-up 动画(见 9.2.3 节)。

步骤 1:创建样式抽离文件

首先创建 collapsible.variants.tsx,使用 cva 来定义 CollapsibleContent 的样式:

文件路径: src/components/ui/collapsible/collapsible.variants.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { cva } from 'class-variance-authority';

// 为 CollapsibleContent 定义样式变体
export const collapsibleContentVariants = cva(
// 基础样式类:所有状态共享
'overflow-hidden text-sm transition-all',
{
variants: {
// 虽然目前我们没有多种变体,但保持架构的扩展性
// 未来如果需要不同的动画效果,可以在这里添加
},
defaultVariants: {},
},
);

// 动画相关的样式常量
// 我们将它们独立出来,因为这些样式是通过 data-[state] 属性动态应用的
export const collapsibleAnimationClasses = {
open: 'data-[state=open]:animate-collapsible-down',
closed: 'data-[state=closed]:animate-collapsible-up',
} as const;

架构思考:虽然 Collapsible 目前不需要像 Button 那样复杂的变体系统,但我们依然遵循相同的架构模式:

  1. 使用 cva 定义基础样式。
  2. 将动画类抽离为常量,便于维护和理解。
  3. 为未来的扩展(如不同的动画效果)预留空间。

🤔 技术深挖:动画是如何工作的?

你可能会好奇:为什么只需要添加 data-[state=open]:animate-collapsible-down 这样的类名,就能实现平滑的折叠动画?这背后涉及三个关键技术的巧妙配合:

1. tailwindcss-animate 的预设动画

当你在 index.css 中添加 @plugin "tailwindcss-animate" 后,这个插件会自动注册 CSS 关键帧动画和对应的 Tailwind 工具类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/* 插件自动生成的 Keyframes */
@keyframes collapsible-down {
from { height: 0; }
to { height: var(--radix-collapsible-content-height); }
}

@keyframes collapsible-up {
from { height: var(--radix-collapsible-content-height); }
to { height: 0; }
}

/* 插件自动生成的 Tailwind 工具类 */
.animate-collapsible-down {
animation: collapsible-down 0.2s ease-out;
}

.animate-collapsible-up {
animation: collapsible-up 0.2s ease-out;
}

2. Radix UI 的 data-[state] 属性

@radix-ui/react-collapsible 会根据组件的打开/关闭状态,自动在 DOM 上添加不同的 data-state 属性:

1
2
3
4
5
<!-- 展开时 -->
<div data-state="open" class="...">内容</div>

<!-- 折叠时 -->
<div data-state="closed" class="...">内容</div>

这是 Radix UI 的设计哲学:通过数据属性而非类名来表示状态

3. Tailwind 的 data-[] 属性选择器

Tailwind v3.4+ 支持 data-[attribute=value]: 前缀语法,它会被编译成标准的 CSS 属性选择器:

1
2
3
4
/* data-[state=open]:animate-collapsible-down 编译为: */
[data-state="open"] {
animation: collapsible-down 0.2s ease-out;
}

完整的工作流程

1
2
3
4
5
6
7
8
9
10
11
用户点击 CollapsibleTrigger

Radix UI 改变内部状态 (open ↔ closed)

Radix UI 更新 DOM 的 data-state 属性

浏览器检测到属性变化,触发对应的 CSS 选择器

执行 collapsible-down/up 动画

用户看到平滑的折叠效果

为什么使用 --radix-collapsible-content-height

因为内容的高度是 动态的(取决于内容多少),Radix UI 会在运行时测量实际高度,并将结果存储为 CSS 变量。这样,动画就能从 0 过渡到 实际高度,而无需硬编码固定值。

这就是现代 UI 库的 “约定大于配置” ——三方都遵循同一套约定,你只需要 “组装” 它们,就能获得开箱即用的动画效果!

步骤 2:创建主组件文件

现在修改 collapsible.tsx,导入并使用抽离的样式:

文件路径: src/components/ui/collapsible/collapsible.tsx (重构)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import * as CollapsiblePrimitive from '@radix-ui/react-collapsible';
import * as React from 'react';

import { cn } from '@/utils/cn';
import {
collapsibleAnimationClasses,
collapsibleContentVariants,
} from './collapsible.variants';

const Collapsible = CollapsiblePrimitive.Root;

const CollapsibleTrigger = CollapsiblePrimitive.CollapsibleTrigger;

type CollapsibleContentElement = React.ComponentRef<typeof CollapsiblePrimitive.CollapsibleContent>;
type CollapsibleContentProps = React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>;

// [核心重构]
// 1. 使用 React.forwardRef 来正确传递 ref。
// 2. 从 variants 文件导入样式,保持主组件文件的简洁性。
const CollapsibleContent = React.forwardRef<
CollapsibleContentElement,
CollapsibleContentProps
>(({ className, children, ...props }, ref) => (
<CollapsiblePrimitive.CollapsibleContent
ref={ref}
className={cn(
collapsibleContentVariants(), // 基础样式来自 variants
collapsibleAnimationClasses.open, // 展开动画
collapsibleAnimationClasses.closed, // 折叠动画
className, // 允许外部覆盖
)}
{...props}
>
{children}
</CollapsiblePrimitive.CollapsibleContent>
));

CollapsibleContent.displayName =
CollapsiblePrimitive.CollapsibleContent.displayName;

export { Collapsible, CollapsibleTrigger, CollapsibleContent };

验证(“GREEN”):保存文件。Storybook 会自动热更新。现在再次访问 UI/Collapsible 故事,点击 “Toggle Content” 按钮——你将看到内容区 平滑地展开和折叠 了。

14.1.4. (Docs) 文档深化:collapsible.mdx

最后,我们为它创建 MDX 文档,使其符合“Button 范式”的交付标准。

创建文件

1
touch src/components/ui/collapsible/collapsible.mdx

编写文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import { Meta, Story, Controls } from '@storybook/blocks';
import * as CollapsibleStories from './collapsible.stories';

<Meta of={CollapsibleStories} />

# Collapsible 可折叠面板

一个可以展开或折叠内容区域的组件。

## 基础用法

`Collapsible` 组件基于 `@radix-ui/react-collapsible` 封装,提供了无障碍访问和灵活的 API。它包含三个核心部分:

- `Collapsible`:根组件,包裹所有内容。
- `CollapsibleTrigger`:触发器,点击它会改变展开/折叠状态。
- `CollapsibleContent`:内容区,它会自动应用 `collapsible-down` 和 `collapsible-up` 动画。

<Story of={CollapsibleStories.Default} />

## API 参考 (Props)

`Collapsible` 根组件的 `props`:

<Controls />

清理 autodocs:最后,回到 collapsible.stories.tsx,移除 tags: ['autodocs'],让 MDX 文件接管文档页。

1
2
3
4
5
6
// src/components/ui/collapsible/collapsible.stories.tsx
const meta: Meta<typeof Collapsible> = {
// ...
// tags: ['autodocs'], // <-- 移除此行
// ...
};

任务 14.1 完成! 我们已经将 Collapsible 完美集成到了我们的 src/ui 库中,并拥有了动画、Storybook 预览和 MDX 文档。


14.2 任务:CDD 构建 (二) - Tooltip

Tooltip(工具提示)是 NavItem 在侧边栏折叠状态下展示 caption(说明文字)的关键组件。当用户悬停在导航项上时,Tooltip 会优雅地浮现出来,提供额外的上下文信息。

14.2.1. (Add) 添加组件基础

同样使用 shadcn/ui CLI 添加 Tooltip 组件骨架:

1
pnpm dlx shadcn@latest add tooltip

执行后,shadcn 会创建 src/components/ui/tooltip/tooltip.tsx 文件,提供基础的 Radix UI 重导出:

文件路径: src/components/ui/tooltip/tooltip.tsx (初始)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
import * as React from "react"
import * as TooltipPrimitive from "@radix-ui/react-tooltip"

import { cn } from "@/utils"

const TooltipProvider = TooltipPrimitive.Provider

const Tooltip = TooltipPrimitive.Root

const TooltipTrigger = TooltipPrimitive.Trigger

const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 overflow-hidden rounded-md bg-primary px-3 py-1.5 text-xs text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-tooltip-content-transform-origin]",
className
)}
{...props}
/>
</TooltipPrimitive.Portal>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName

export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

架构观察:与 Collapsible 不同,shadcnTooltip 提供了一些默认样式。但这些样式都 硬编码在组件内部,违背了我们 “样式与逻辑分离” 的架构原则。我们需要将它们重构到 variants 文件中。

14.2.2. (Story) Storybook 先行

创建 Storybook 故事,先 “看见” 当前的 Tooltip:

创建文件

1
touch src/components/ui/tooltip/tooltip.stories.tsx

编写故事

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
// src/components/ui/tooltip/tooltip.stories.tsx

import type { Meta, StoryObj } from '@storybook/react-vite';
import { Button } from '../button/button';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from './tooltip';

const meta: Meta<typeof Tooltip> = {
title: 'UI/Tooltip',
component: Tooltip,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
open: {
control: 'boolean',
description: '受控:是否显示 Tooltip',
},
defaultOpen: {
control: 'boolean',
description: '非受控:默认是否显示',
},
delayDuration: {
control: 'number',
description: '悬停多久后显示(毫秒)',
},
},
};

export default meta;
type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: {},
render: (args) => (
<TooltipProvider>
<Tooltip {...args}>
<TooltipTrigger asChild>
<Button variant="outline">Hover me</Button>
</TooltipTrigger>
<TooltipContent>
<p>这是一个工具提示</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
),
};

// 展示不同方向的 Tooltip
export const Sides: Story = {
render: () => (
<TooltipProvider>
<div className="flex gap-4">
<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Top</Button>
</TooltipTrigger>
<TooltipContent side="top">
<p>顶部提示</p>
</TooltipContent>
</Tooltip>

<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Right</Button>
</TooltipTrigger>
<TooltipContent side="right">
<p>右侧提示</p>
</TooltipContent>
</Tooltip>

<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Bottom</Button>
</TooltipTrigger>
<TooltipContent side="bottom">
<p>底部提示</p>
</TooltipContent>
</Tooltip>

<Tooltip>
<TooltipTrigger asChild>
<Button variant="outline">Left</Button>
</TooltipTrigger>
<TooltipContent side="left">
<p>左侧提示</p>
</TooltipContent>
</Tooltip>
</div>
</TooltipProvider>
),
};

验证(“RED”):运行 pnpm storybook。你会看到 Tooltip 可以正常工作,但所有样式都写在组件内部,不符合我们的架构标准。

14.2.3. 编码实现:样式抽离与动画优化

步骤 1:创建样式抽离文件

创建 tooltip.variants.tsx,使用 cva 管理样式:

文件路径: src/components/ui/tooltip/tooltip.variants.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { cva } from 'class-variance-authority';

// TooltipContent 的样式变体
export const tooltipContentVariants = cva(
// 基础样式:布局、间距、字体
[
'z-50',
'overflow-hidden',
'rounded-md',
'px-3 py-1.5',
'text-xs',
// 颜色主题(使用语义化颜色)
'bg-primary',
'text-primary-foreground',
// Transform Origin(使用 Radix UI 提供的 CSS 变量)
'origin-[--radix-tooltip-content-transform-origin]',
],
{
variants: {
// 未来可以在这里添加不同的视觉风格
// 例如:variant: { default, dark, light }
},
defaultVariants: {},
},
);

// 动画相关的样式常量
export const tooltipAnimationClasses = {
// 入场动画
in: [
'animate-in',
'fade-in-0',
'zoom-in-95',
].join(' '),

// 出场动画
out: [
'data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0',
'data-[state=closed]:zoom-out-95',
].join(' '),

// 基于显示方向的滑入动画
side: [
'data-[side=bottom]:slide-in-from-top-2',
'data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2',
'data-[side=top]:slide-in-from-bottom-2',
].join(' '),
} as const;

架构思考
Tooltip 的动画比 Collapsible 更复杂,它包含三个层次:

  1. 入场动画:淡入 + 缩放 (fade-in + zoom-in)
  2. 出场动画:淡出 + 缩放 (fade-out + zoom-out)
  3. 方向动画:根据 Tooltip 的显示位置(上/下/左/右),从对应方向滑入

我们将这三类动画分别抽离为常量,保持代码的可读性和可维护性。

步骤 2:重构主组件文件

修改 tooltip.tsx,导入并使用抽离的样式:

文件路径: src/components/ui/tooltip/tooltip.tsx (重构)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
import * as React from 'react';
import * as TooltipPrimitive from '@radix-ui/react-tooltip';

import { cn } from '@/utils/cn';
import {
tooltipAnimationClasses,
tooltipContentVariants,
} from './tooltip.variants';

const TooltipProvider = TooltipPrimitive.Provider;

const Tooltip = TooltipPrimitive.Root;

const TooltipTrigger = TooltipPrimitive.Trigger;

type TooltipContentElement = React.ComponentRef<typeof TooltipPrimitive.Content>;
type TooltipContentProps = React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>;

const TooltipContent = React.forwardRef<
TooltipContentElement,
TooltipContentProps
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
tooltipContentVariants(), // 基础样式来自 variants
tooltipAnimationClasses.in, // 入场动画
tooltipAnimationClasses.out, // 出场动画
tooltipAnimationClasses.side, // 方向滑入动画
className, // 允许外部覆盖
)}
{...props}
/>
</TooltipPrimitive.Portal>
));

TooltipContent.displayName = TooltipPrimitive.Content.displayName;

export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };

验证(“GREEN”):保存文件,Storybook 自动热更新。现在访问 UI/Tooltip 故事,悬停在按钮上——你将看到 Tooltip 以优雅的动画弹出,样式已经完全从组件中抽离出来了。

14.2.4. (Docs) 文档深化:tooltip.mdx

创建 MDX 文档:

创建文件

1
touch src/components/ui/tooltip/tooltip.mdx

编写文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
import { Meta, Story, Controls } from '@storybook/blocks';
import * as TooltipStories from './tooltip.stories';

<Meta of={TooltipStories} />

# Tooltip 工具提示

一个悬浮提示组件,用于展示额外的说明信息。

## 基础用法

`Tooltip` 组件基于 `@radix-ui/react-tooltip` 封装,提供了完整的无障碍支持。它包含四个核心部分:

- `TooltipProvider`:提供全局配置(如延迟时长)的上下文,通常包裹在应用的顶层。
- `Tooltip`:根组件,管理单个 Tooltip 的状态。
- `TooltipTrigger`:触发器,用户悬停在它上面时显示 Tooltip。
- `TooltipContent`:内容区,显示提示信息,支持四个方向 (`top` / `right` / `bottom` / `left`)。

<Story of={TooltipStories.Default} />

## 显示方向

Tooltip 支持四个方向显示,通过 `side` prop 控制:

<Story of={TooltipStories.Sides} />

## API 参考 (Props)

`Tooltip` 根组件的 `props`:

<Controls />

## 使用建议

- **简洁性**:Tooltip 内容应简短明了,避免长篇大论。
- **必要性**:只在需要额外说明时使用,不要过度使用导致界面混乱。
- **无障碍性**:Tooltip 内容应该是**补充信息**,而非必须阅读的关键信息,因为它在键盘导航时可能难以触发。

清理 autodocs

1
2
3
4
5
6
// src/components/ui/tooltip/tooltip.stories.tsx
const meta: Meta<typeof Tooltip> = {
// ...
// tags: ['autodocs'], // <-- 移除此行
// ...
};

任务 14.2 完成! 我们已经将 Tooltip 完美集成到了我们的 UI 库中,并保持了与 ButtonCollapsible 一致的架构模式。


14.3 本章小结与架构回顾

在本章中,我们成功构建了 NavVertical 组件所需的两个核心 UI 依赖:CollapsibleTooltip。更重要的是,我们通过这个过程 巩固并深化 了我们在第八、九章建立的 CDD(组件驱动开发)架构模式

14.3.1. 我们做了什么?

📦 交付成果

  1. Collapsible 组件

    • collapsible.tsx:主组件文件
    • collapsible.variants.tsx:样式抽离文件
    • collapsible.stories.tsx:Storybook 故事
    • collapsible.mdx:组件文档
  2. Tooltip 组件

    • tooltip.tsx:主组件文件
    • tooltip.variants.tsx:样式抽离文件
    • tooltip.stories.tsx:Storybook 故事
    • tooltip.mdx:组件文档

🏗️ 架构一致性

所有组件都遵循相同的 “三层架构”:

1
2
3
4
5
组件目录/
├── component.tsx # 逻辑层:组件行为、props、ref 转发
├── component.variants.tsx # 样式层:CVA 变体 + 动画常量
├── component.stories.tsx # 测试层:交互验证、视觉回归
└── component.mdx # 文档层:使用指南、API 参考

这种架构让我们的代码具备了:

  • 可维护性:样式修改不触及组件逻辑
  • 可测试性:Storybook 提供可视化验证
  • 可复用性:通过 cva 轻松扩展变体
  • 可文档化:MDX 让文档与代码同步

14.3.2. 关键技术洞察

动画系统的工作原理

我们在 14.1.3 节深入剖析了 tailwindcss-animate + Radix UI + Tailwind 的 “约定配合” 机制:

  1. tailwindcss-animate 提供预设的 CSS 关键帧动画
  2. Radix UI 通过 data-[state]data-[side] 属性暴露组件状态
  3. Tailwinddata-[] 选择器将状态与动画连接起来
  4. CSS 变量 (如 --radix-collapsible-content-height) 让动画适配动态内容

这种设计让我们能用 声明式的类名 实现复杂的、自适应的动画效果。

样式抽离的层次

组件基础样式状态动画复杂度
Button6 种 variant + 4 种 size❌ 无⭐⭐
Collapsible单一基础样式2 种状态(open/closed)
Tooltip单一基础样式3 层动画(in/out/side)⭐⭐⭐

可以看出,即使组件复杂度不同,我们依然能用统一的 variants 模式来管理样式。

14.3.3. 为什么跳过 TDD?

与第八章的 Button 不同,CollapsibleTooltip 我们 跳过了单元测试(TDD),原因是:

  1. 它们是 Radix UI 的薄封装:核心功能已经在 Radix UI 中被充分测试
  2. 我们的价值是样式层:我们的重构主要是样式抽离,而样式的正确性通过 Storybook 的视觉验证 更直观
  3. 避免测试实现细节:测试 “className 是否正确拼接” 属于实现细节,而非行为契约

架构原则

  • 行为复杂的业务组件(如 Button 的交互逻辑) → TDD
  • UI 封装组件(如 CollapsibleTooltip) → CDD (Storybook 验证)

14.3.4. 代码入库

现在,让我们将这些成果提交到 Git 仓库:

步骤 1:检查文件变更

1
git status

你应该看到以下新增文件:

1
2
3
4
5
6
7
8
9
10
11
src/components/ui/collapsible/
- collapsible.tsx
- collapsible.variants.tsx
- collapsible.stories.tsx
- collapsible.mdx

src/components/ui/tooltip/
- tooltip.tsx
- tooltip.variants.tsx
- tooltip.stories.tsx
- tooltip.mdx

步骤 2:暂存文件

1
git add .

步骤 3:提交更改

1
2
3
4
5
6
git commit -m "feat: add Collapsible and Tooltip UI components" 
-m "- Add Collapsible component with collapse/expand animations"
-m "- Add Tooltip component with multi-directional display"
-m "- Extract styles to variants files using CVA pattern"
-m "- Add Storybook stories and MDX documentation"
-m "- Follow Button architecture for consistency"

提交信息解读

  • 类型feat(新功能)
  • 范围:UI 组件
  • 主题:简洁说明添加了什么
  • 详情:列出关键变更点,方便后续代码审查和历史回溯

步骤 4:验证提交

1
git log --oneline -1

确认提交记录已成功创建。

14.3.5. 下一步预告

在第十五章,我们将迎来本项目的 第一个复杂业务组件NavVertical(垂直导航菜单)。

它将整合我们目前构建的所有 “积木”:

1
2
3
4
5
6
7
NavVertical (第十五章)
├── 使用 useNavData Hook (第十三章)
├── 渲染 NavGroup 和 NavList
│ └── 使用 Collapsible (第十四章)
└── 渲染 NavItem
├── 使用 Button 样式 (第八章)
└── 使用 Tooltip 展示 caption (第十四章)

这将是我们对 组件组合(Composition) 能力的一次集大成检验。准备好了吗?让我们继续前进!