第九章. ui 启动:shadcn/ui 与企业级主题融合

第九章. src/ui 启动:shadcn/ui 与企业级主题融合

在第八章中,我们以“手工作坊”的模式,从零打造了 Button 原子组件。这个过程的价值在于让我们深刻理解了 shadcn/ui 模式的“灵魂”:cva 的变体管理、forwardRef 的引用传递、Slot 的多态能力以及 cn 的样式合并。

现在,我们已经掌握了“如何造轮子”,是时候切换到“如何高效、规模化地生产轮子”了。

在本章中,我们将正式引入 shadcn/ui CLI——它不是一个库,而是一个强大的 代码生成器和工作流工具。但我们将面临一个真实的企业级挑战:Prorise-Admin 已经拥有 一个基于 Vanilla ExtractAnt Design Token 的复杂主题系统。

我们的核心任务 不是 简单地运行 npx shadcn init 并接受它的默认 HSL 变量,这将导致我们的项目中出现 两套设计规范,造成灾难性的“主题分裂”。

相反,我们将扮演 项目架构师 的角色,执行一个 精密的集成策略:我们将“劫持”shadcn/ui 的 CSS 变量系统,通过一个巧妙的“CSS 桥接层”,使其组件 100% 消费我们已有的 Vanilla Extract 主题 Token

9.1. 战略制定:从“手工”到“模式”,再到“融合”

9.1.1. 回顾:Button 带给我们的启示

在第八章,我们花费了大量精力手动创建了 src/components/ui/button/button.tsx。我们学到了:

  1. cva 如何帮助我们用声明式的方式管理 variantsize 的组合。
  2. @radix-ui/react-slotasChild prop 如何让我们的组件具备“多态”能力,既能是 button 也能是 a
  3. forwardRef 对于表单、浮层等组件的必要性。
  4. cn (tailwind-merge + clsx) 如何解决样式覆盖和冲突。

这四个要素,就是 shadcn/ui 模式的全部技术核心。shadcn/ui 所做的,就是把这个模式封装成一个 CLI 工具,让我们不再需要为每一个 Input, Dialog, Card… 去重复编写这些“样板代码”。

9.1.2. 挑战:shadcn/ui 的“特洛伊木马”

当我们准备执行 npx shadcn init 时,我们必须意识到它会做什么:

  1. 安装依赖@radix-ui/*, cva, tailwind-merge 等。(这很好)

  2. 创建 components.json:一个配置文件,告诉 CLI 我们的路径别名等。(这很好)

  3. 注入 cn 工具:在 src/utils/cn.ts。(我们已经有了,可以覆盖)

  4. 【核心问题】注入 CSS 变量:它会打开我们的全局 CSS 文件(在 prorise-admin 项目中是 src/index.css),并 注入一大堆 HSL 格式的 CSS 变量,像这样:

    1
    2
    3
    4
    5
    6
    7
    8
    /* 这是 shadcn 的默认输出 (错误的做法) */
    :root {
    --background: 0 0% 100%;
    --foreground: 222.2 47.4% 11.2%;
    --primary: 222.2 47.4% 11.2%;
    --primary-foreground: 210 40% 98%;
    /* ...等等几十个 HSL 变量 */
    }

这就是“特洛伊木马”。如果我们接受了这些变量,我们的项目就完了。

为什么?因为在 第五章第六章,我们已经呕心沥血地用 Vanilla Extract 构建了一套完整的主题系统(src/theme/theme.css.ts)。我们所有的颜色(如 colors.palette.primary.default)都来自 Ant Design 的 Token,并且是 动态的(支持明暗模式切换)。

shadcn 默认的 HSL 变量是 静态的。这会导致:

  • 我们的 Button(第八章)使用 VE 变量,颜色正确且能切换主题。
  • 我们的 Input(本章)使用 shadcn HSL 变量,颜色 错误,且 无法 切换主题。
  • 项目中同时存在 var(--colors-palette-primary-default)var(--primary) 两种变量,维护性为零。

9.1.3. 架构决策:构建“CSS 桥接层”

我们的策略不是二选一,而是 融合

shadcn 组件(如 Input)的样式是这样写的:bg-background border-input
tailwind.config.ts 会把它们翻译成:

  • background-color: hsl(var(--background))
  • border-color: hsl(var(--input))

shadcn 并不关心 var(--background) 是什么,它只关心这个 变量名

这就给了我们一个绝佳的“劫持”机会。我们的解决方案是:

  1. 允许 shadcn init 运行,但 立即删除 它注入到 src/index.css 的所有 :root { ... } 变量。
  2. 取而代之,我们将手动适配那段“桥接层 CSS 写入 src/index.css

让我们来看看我们后续提供的这段 CSS 代码,它正是我们架构的完美体现:

文件路径: src/index.css (重构后)

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
/* ... (import 语句) ... */

/* config tailwind */
@config "../tailwind.config.ts";

/* ... (base layer) ... */

/* ... (theme keyframes) ... */

/* =================================================
shadcn/ui 主题桥接层 (亮色)
=================================================
我们在这里定义 shadcn 期望的 CSS 变量 (如 --background),
但我们将它们的值“指向”我们已有的 Vanilla Extract 变量
(如 var(--colors-common-white)).
*/
:root {
--radius: 0.5rem; /* shadcn 圆角变量 */

/* 1. 背景/前景 (Background / Foreground) */
--background: var(--colors-common-white);
--foreground: var(--colors-common-black);

/* 2. 卡片 (Card) */
--card: var(--colors-common-white);
--card-foreground: var(--colors-common-black);

/* 3. 浮层 (Popover) */
--popover: var(--colors-common-white);
--popover-foreground: var(--colors-common-black);

/* 4. 主色 (Primary) */
--primary: var(--colors-palette-primary-default);
--primary-foreground: var(--colors-palette-primary-lighter);

/* 5. 次色 (Secondary) */
--secondary: var(--colors-palette-gray-200);
--secondary-foreground: var(--colors-palette-gray-600);

/* ... (muted, accent) ... */

/* 6. 危险色 (Destructive) */
--destructive: var(--colors-palette-error-default);

/* 7. 边框 (Border / Input) */
/* 注意:这里我们甚至映射到了一个 Channel 变量,以支持 opacity */
--border: rgba(var(--colors-palette-gray-500-channel) / var(--opacity-border));
--input: rgba(var(--colors-palette-gray-500-channel) / var(--opacity-border));

/* 8. 焦点环 (Ring) */
--ring: var(--colors-palette-primary-default);

/* ... (sidebar, chart) ... */
}

/* =================================================
shadcn/ui 主题桥接层 (暗色)
=================================================
利用我们已有的 [data-theme-mode="dark"] 选择器,
shadcn 组件将自动切换到暗色模式。
*/
[data-theme-mode="dark"] {
/* 1. 背景/前景 */
--background: var(--colors-common-black);
--foreground: var(--colors-common-white);

/* 2. 卡片 */
--card: var(--colors-common-black);
--card-foreground: var(--colors-common-white);

/* ... (其他所有变量的暗色模式映射) ... */

--secondary: var(--colors-background-neutral);
--secondary-foreground: var(--colors-palette-gray-500);

/* ... etc ... */
}

/* ... ( layout 变量) ... */

战略总结

通过这层“桥接”,我们实现了一个 三赢 的局面:

  1. shadcn/ui (获胜)npx shadcn add 可以正常工作。它生成的组件(如 Input)所依赖的 bg-background 等 Tailwind 类依然有效。
  2. Vanilla Extract 主题 (获胜):我们 唯一 的设计规范来源仍然是 src/theme/theme.css.ts。我们所有的颜色定义(--colors-common-white 等)都被正确消费了。
  3. 开发者 (获胜):我们获得了 shadcn/ui 的开发效率,同时保持了项目主题的绝对统一和动态切换能力。

9.2. 【核心】shadcn/ui 初始化 (init)

这是我们将 shadcn/ui 模式集成到 Prorise-Admin 项目中的 最关键一步init 命令不仅会安装依赖,更重要的是,它会创建 components.json 配置文件,并尝试修改我们现有的 tailwind.config.tssrc/index.css

我们的目标是:引导 init 命令,使其“为我所用”,为我们 9.1 节制定的“CSS 桥接层”战略铺平道路。

9.2.1. 执行 init 命令

在项目根目录打开终端,执行:

1
pnpm dlx shadcn@latest init

我们使用 pnpm dlx 来执行 npx 命令,这是 pnpm 中推荐的执行包的方式。它会临时下载 shadcn-ui CLI 包并运行它,而不会将其作为项目的永久依赖。

9.2.2. 交互式向导

执行后,shadcn-ui 会启动一个交互式向导。这是至关重要的一步,我们的选择将决定它如何与我们现有的项目集成。

以下是我们在 Prorise-Admin 项目中 必须 填写的“标准答案”:

1
2
3
4
5
6
? Which color would you like to use as the base color? » - Use arrow-keys. Return to submit.
> Neutral
Gray
Zinc
Stone
Slate

9.2.3. 审查 init 的“三板斧” (The Three Outputs)

init 命令执行完毕后,它会对我们的项目做三件(或四件)事。我们必须像架构师一样逐一审查。

A. components.json (新大脑)

init 在我们的项目根目录创建了一个新文件 components.json

文件路径./components.json (示例)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/utils", // 注意,我们的cn函数是在util文件夹里面,默认shadcn会生成lib/utils文件,我们删除掉他生成的指向我们的
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

架构解析

  • 这是 shadcn CLI 的“大脑”。未来我们运行 npx shadcn add 时,CLI 会读取这个文件。
  • tailwind.css: 告诉 add 命令要去哪里 注入新的 CSS 变量(如果我们不阻止它)。
  • aliases.components: 告诉 add 命令,Input 组件应该被创建在 src/components/ui/input.tsx
  • aliases.utils: 告诉 add 命令,当 Input 依赖 cn 函数时,它的 import 路径应该是 import { cn } from '@/utils'

B. src/index.css (执行 “适配”)

init 命令会在我们 src/index.css 中注入默认的、基于 neutral 颜色的、静态的 OKLCH 变量。

1
2
3
4
5
6
7
8
9
10
11
12
/* src/index.css (被 init 修改后) */

:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
/* ... 一大堆 OKLCH 值 ... */
}

.dark {
--background: oklch(0.145 0 0);
/* ... 一大堆 OKLCH 值 ... */
}

这就是我们需要适配的地方。我们要将这些静态值替换为对我们 Vanilla Extract 主题令牌的引用。

执行我们的战略

  1. 打开 src/index.css
  2. 替换 init 命令添加的静态颜色值,改为引用我们的主题令牌。
  3. 修改 暗色模式选择器,从 .dark 改为 :root[data-theme-mode="dark"],与我们的 ThemeProvider 保持一致。
  4. 添加 @theme inline 块,将 shadcn 变量桥接到 Tailwind 的颜色系统。

最终的 src/index.css 结构应该是这样的:

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
98
/* ========================================
Tailwind CSS 导入
======================================== */
@plugin "tailwindcss-animate";
@import "tailwindcss";
@import "tw-animate-css";

/* ========================================
配置文件声明
======================================== */
@config "../tailwind.config.ts";

/* ========================================
暗色模式变体定义
======================================== */
/* 定义 dark 变体,与我们的 ThemeProvider 配合使用 */
@variant dark (&:is([data-theme-mode="dark"] *));

/* ========================================
shadcn/ui 适配层
======================================== */
/* [1] 圆角系统 - 映射到我们的 borderRadius tokens */
@theme inline {
--radius: var(--borderRadius-default);
--radius-sm: var(--borderRadius-sm);
--radius-md: var(--borderRadius-md);
--radius-lg: var(--borderRadius-lg);
--radius-xl: var(--borderRadius-xl);

/* [2] shadcn 颜色系统 → Tailwind 颜色前缀 */
/* Tailwind 需要 --color- 前缀才能识别为颜色 */
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-destructive: var(--destructive);
/* ... 更多颜色映射 ... */
}

/* ========================================
shadcn 变量定义 - 亮色模式
======================================== */
:root {
/* [1] 基础颜色 - 映射到我们的主题令牌 */
--background: var(--colors-background-default-value);
--foreground: var(--colors-text-primary-value);

/* [2] 卡片颜色 */
--card: var(--colors-background-paper-value);
--card-foreground: var(--colors-text-primary-value);

/* [3] 主色调 - 注意使用 -value 后缀 */
--primary: var(--colors-palette-primary-default-value);
--primary-foreground: var(--colors-common-white-value);

/* [4] 破坏性操作色 */
--destructive: var(--colors-palette-error-default-value);
--destructive-foreground: var(--colors-common-white-value);

/* [5] 边框和输入框 - 使用 -channel 后缀支持透明度 */
--border: rgba(var(--colors-palette-gray-500-channel) / var(--opacity-border));
--input: rgba(var(--colors-palette-gray-500-channel) / var(--opacity-border));

/* [6] 焦点环 - 使用 channel 实现动态透明度 */
--ring: rgba(var(--colors-palette-primary-default-channel) / 0.3);

/* ... 更多颜色映射 ... */
}

/* ========================================
shadcn 变量定义 - 暗色模式
======================================== */
/* 关键:使用 :root[data-theme-mode="dark"] 而不是 .dark */
:root[data-theme-mode="dark"] {
/* Vanilla Extract 会自动切换底层令牌的值 */
--background: var(--colors-background-default-value);
--foreground: var(--colors-text-primary-value);

--primary: var(--colors-palette-primary-default-value);
--primary-foreground: var(--colors-common-white-value);

/* 暗色模式下,边框使用白色的低透明度 */
--border: rgba(var(--colors-common-white-channel) / 0.1);
--input: rgba(var(--colors-common-white-channel) / 0.15);

/* ... 更多暗色变量 ... */
}

/* ========================================
基础样式层
======================================== */
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

关键适配要点

  1. 变量命名规则:Vanilla Extract 生成的变量名遵循 --{path}-{property} 格式

    • 颜色值:--colors-palette-primary-default-value
    • 颜色通道:--colors-palette-primary-default-channel
    • 注意使用连字符 - 而不是驼峰命名
  2. 暗色模式选择器:使用 :root[data-theme-mode="dark"] 而不是 .dark 类名

    • 这与我们的 ThemeProvider 使用 data-theme-mode 属性保持一致
    • 确保暗色模式切换机制统一
  3. 透明度支持:充分利用 channel 变量

    • rgba(var(--colors-palette-gray-500-channel) / var(--opacity-border))
    • 这使得边框和焦点环可以根据主题色动态变化
  4. Tailwind 颜色前缀:使用 @theme inline 添加 --color- 前缀

    • Tailwind v4 需要 --color- 前缀才能识别为颜色
    • 这确保了 bg-backgroundtext-foreground 等类名正常工作

任务 9.2 已完成!

我们成功地“驯服”了 shadcn init。我们利用了它的自动化配置(components.json, tailwind.config.ts),并 替换 了它的默认主题,注入 了我们自己的企业级“CSS 桥接层”。

现在,Prorise-Admin 已经准备好,可以 安全地 添加 shadcn 组件了。


9.3. Input 组件的集成与定制 (CDD 驱动)

我们的“CSS 桥接层”已经就位。现在是收获成果的时候了。我们将添加 Input 组件,并立即对其进行定制,使其完美融合我们的设计系统。

9.3.1. 添加 Input 组件

在终端中,我们执行 shadcn add 命令:

1
pnpm dlx shadcn@latest add input

shadcn CLI 会读取 components.json,找到我们的配置:

  • 目标路径aliases.ui -> @/components/ui
  • 工具路径aliases.utils -> @/utils

它会为我们创建 src/components/ui/input.tsx。这个“原始”文件会包含 cva 变体和大量 shadcn 默认的 Tailwind 类。

然而,在 Prorise-Admin 中,我们有自己更精细的设计规范。我们将 用我们自己的定制版本覆盖 这个文件。

9.3.2. 架构决策:样式分离与 cva 管理

重要说明:由于 shadcn/ui 生成的组件质量很高,通常不会有问题,因此我们 可以省略之前 TDD 流程中的 test 文件编写步骤。我们只需要在生成的基础上进行增删和定制即可。

Prorise-Admin 中,我们采用与 Button 组件一致的架构:将样式抽离到单独的 variants 文件中,使用 cva 进行管理

回顾我们的 Button 组件结构:

1
2
3
4
5
src/components/ui/button/
├── button.tsx # 组件主体
├── button.variants.tsx # cva 样式定义
├── button.stories.tsx # Storybook 故事
└── button.test.tsx # 单元测试

这种结构的优势在于:

  1. 关注点分离:组件逻辑与样式定义分离,代码更清晰。
  2. 可维护性:样式集中管理,修改时不需要在组件文件中搜索。
  3. 可扩展性:使用 cva 可以轻松添加新的变体和尺寸。
  4. 一致性:所有 UI 组件遵循相同的架构模式。

我们将为 Input 组件采用相同的架构,即使它当前只有一种基础形态。这为未来可能的扩展(如添加 size 变体或 variant 变体)预留了空间。

9.3.3. 编码实现:创建 input.variants.tsx

首先,我们创建样式定义文件,将所有 Tailwind 类抽离到 cva 中。shadcn 生成的默认样式已经很好,但我们需要引入我们自己的设计系统样式,并与 Vanilla Extract 主题系统完美融合。

文件路径src/components/ui/input/input.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
import { cva } from "class-variance-authority";

export const inputVariants = cva(
[
// [1] 基础布局和尺寸
"flex h-9 w-full min-w-0 rounded-md border px-3 py-1",

// [2] 颜色系统 - 消费我们的 CSS 桥接变量
"border-input bg-transparent",
"text-base text-foreground",
"placeholder:text-muted-foreground",

// [3] 暗色模式定制
"dark:bg-input/30",

// [4] 选中文本样式
"selection:bg-primary selection:text-primary-foreground",

// [5] 阴影和过渡
"shadow-sm transition-colors",

// [6] 聚焦状态 - 消费我们的 ring 变量
"outline-none",
"focus-visible:ring-1 focus-visible:ring-ring",

// [7] 禁用状态
"disabled:cursor-not-allowed disabled:opacity-50",

// [8] 失效状态 - 消费我们的 destructive 变量
"aria-invalid:border-destructive aria-invalid:ring-destructive/20",
"dark:aria-invalid:ring-destructive/40",

// [9] 文件上传样式
"file:border-0 file:bg-transparent file:text-sm file:font-medium",
"file:text-foreground",

// [10] 响应式调整
"md:text-sm",
],
{
variants: {
// 预留:未来可以在这里添加 size、variant 等变体
},
defaultVariants: {
// 预留:默认变体配置
},
}
);

架构深度解析

  1. 使用数组格式:更易读,每个样式分类一目了然。
  2. CSS 桥接变量border-inputtext-foregroundring-ringaria-invalid:border-destructive 等类完美消费了我们在 9.2 节中定义的桥接变量。
  3. 暗色模式dark:bg-input/30 利用了我们在 src/index.css 中定义的 @variant dark 规则。它会在暗色模式下,使用我们的 var(--input) 变量(在暗色模式下被桥接到 rgba(var(--colors-common-white-channel) / 0.15))并应用 30% 的透明度。
  4. 预留扩展空间:虽然当前只有一种形态,但 cvavariantsdefaultVariants 配置已经就位,未来可以轻松添加 size: ['sm', 'default', 'lg']variant: ['default', 'filled', 'outlined'] 等变体。
  5. 完整的状态覆盖:聚焦、失效、禁用、文件上传等所有状态都有对应的样式定义。

9.3.4. 编码实现:重构 input.tsx

现在,我们重构组件主体文件,使其与 Button 组件保持一致的结构:

文件路径src/components/ui/input/input.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
import * as React from "react";
import type { VariantProps } from "class-variance-authority";
import { cn } from "@/utils/cn";
import { inputVariants } from "./input.variants";

// [1] 使用 VariantProps 推断 variants 类型(预留)
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement>,
VariantProps<typeof inputVariants> {}

// [2] 使用 forwardRef 支持 ref 传递
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
data-slot="input" // [3] 便于 E2E 测试和 CSS 调试
className={cn(inputVariants({ className }))}
ref={ref}
{...props}
/>
);
}
);
Input.displayName = "Input";

export { Input };

架构深度解析

  1. VariantProps<typeof inputVariants>:虽然当前没有变体,但这个类型定义为未来扩展预留了空间。一旦我们在 inputVariants 中添加 sizevariant,TypeScript 会自动推断出对应的 props 类型。
  2. React.forwardRef:与 Button 组件一致,支持 ref 传递,这对于表单库(如 react-hook-form)的集成至关重要。
  3. data-slot="input":为组件的根元素添加稳定的 data- 属性,便于测试和调试。
  4. cn(inputVariants({ className })):使用 cva 生成的 inputVariants 函数,并通过 cn 合并外部传入的 className
  5. displayName:在 React DevTools 中显示清晰的组件名称。

结论:经过重构,Input 组件现在完全遵循了我们项目的架构规范,与 Button 组件保持一致。所有样式都由我们自己的 Vanilla Extract 主题系统驱动,并且为未来的扩展预留了灵活的空间。

9.3.5. CDD 实践:在 Storybook 中验证融合

现在,我们在 Storybook 中创建 input.stories.tsx亲眼见证 我们的主题融合。

注意:关于 .mdx 文档文件和 .test.tsx 测试文件,我们暂时不创建。.mdx 文档我们会在后续专门的章节中统一处理;而测试方面,由于 shadcn/ui 生成的组件质量已经很高,我们可以省略基础测试,只在需要时针对特定功能编写测试。

文件路径src/components/ui/input.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
import type { Meta, StoryObj } from '@storybook/react';
import { Input } from './input';

// [1] 配置 Meta
const meta: Meta<typeof Input> = {
title: 'UI/Input',
component: Input,
parameters: {
layout: 'centered',
},
// [2] 自动生成文档
tags: ['autodocs'],
argTypes: {
type: {
control: 'select',
options: ['text', 'password', 'email', 'number'],
},
placeholder: { control: 'text' },
disabled: { control: 'boolean' },
"aria-invalid": { control: 'boolean' }, // [3] 模拟失效状态
},
};

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

// [4] 基础故事
export const Default: Story = {
args: {
type: 'text',
placeholder: '请输入...',
},
};

// [5] 禁用状态
export const Disabled: Story = {
args: {
...Default.args,
disabled: true,
},
};

// [6] 失效状态 (aria-invalid)
export const Invalid: Story = {
args: {
...Default.args,
"aria-invalid": true, // [7] 关键!
},
};

// [8] 密码类型
export const Password: Story = {
args: {
type: 'password',
placeholder: '请输入密码...',
},
};

启动 Storybook

1
pnpm storybook

验证我们的架构

  1. 基础样式:打开 Default 故事。Input 的边框颜色 border-input 现在是由我们的 var(--colors-palette-gray-500-channel) 决定的。
  2. 焦点样式:点击 Inputfocus-visible:ring-ring 的颜色现在是我们 Prorise-Admin主色var(--colors-palette-primary-default)),而不是 shadcnslate 色。
  3. 失效样式:打开 Invalid 故事。aria-invalid:ring-destructive 的颜色现在是我们 Prorise-Admin错误色var(--colors-palette-error-default))。
  4. 【终极验证】主题切换
    • 点击 Storybook 工具栏的“月亮”图标,切换到暗色模式。
    • 你会看到 Input 的背景变成了 dark:bg-input/30(即我们定义的 rgba(var(--colors-common-white-channel) / 0.15)),边框颜色也相应改变。
    • 所有焦点色、失效色均 正确地切换到了暗色模式下的对应值

最终架构

现在,我们的 Input 组件结构与 Button 组件完全一致:

1
2
3
4
src/components/ui/input/
├── input.tsx # 组件主体
├── input.variants.tsx # cva 样式定义
└── input.stories.tsx # Storybook 故事

这个结构展示了我们项目的核心架构原则:

  1. 样式分离:使用 cva 将样式抽离到独立文件,代码更清晰。
  2. 主题集成:所有颜色和状态样式都消费我们的 CSS 桥接变量,完美融合 Vanilla Extract 主题系统。
  3. 一致性:所有 UI 组件遵循相同的架构模式。
  4. 可扩展性:预留了 variants 配置空间,未来可以轻松添加新的变体。
  5. 类型安全:使用 VariantProps 和 TypeScript 接口,提供完整的类型推断。

任务 9.3 已完成!

我们成功地添加并 深度定制Input 组件,使其与 Button 组件保持一致的架构规范,并通过 Storybook (CDD) 完全验证 了我们的 “CSS 桥接层” 架构。


9.4. Label 组件的集成与融合 (a11y 驱动)

Input 组件很少单独存在,它需要一个可访问的 Label。在本节中,我们将添加 Label 组件,并将其重构为我们项目的标准架构,重点关注它如何通过 Radix UI 原语实现可访问性。

9.4.1. 添加 Label 组件依赖

首先,我们运行 shadcn add 命令。我们执行此操作的主要目的 不是为了获取代码(因为我们将重写它),而是为了确保 shadcn 为我们自动安装 核心依赖,即 @radix-ui/react-label

1
pnpm dlx shadcn@latest add label

CLI 会在 src/components/ui/ 目录下创建 label.tsx。我们将 忽略 这个文件的默认内容,并立即开始重构。

9.4.2. 编码实现:创建 label.variants.tsx

Input 一样,我们首先创建样式定义文件。我们从 Shadcn 提供的 Label 代码中提取所有 Tailwind 类,并将它们放入 cva 中,病整理好

文件路径src/components/ui/label/label.variants.tsx

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

export const labelVariants = cva(
[
// [1] 基础样式
"flex items-center gap-2 text-sm leading-none font-medium select-none",

// [2] 禁用状态 (当 Label 包裹在一个 data-[disabled=true] 的父元素中时)
"group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50",

// [3] 禁用状态 (当 Label 关联的 <input> 元素被禁用时)
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
]
// 注意:我们没有消费 `text-foreground`,
// 因为 Radix Label 默认会继承父级的文本颜色,
// 这符合我们的预期。
);

架构深度解析

  1. peer-disabled (核心):这是 Label 组件实现可访问性的关键样式。当 Label 通过 htmlFor 属性指向一个 Input(即 “peer” - 同级元素)时,如果该 Input 处于 disabled 状态,Label 会自动应用 peer-disabled: 的样式(通常是半透明和禁止光标),为用户提供清晰的视觉反馈。
  2. group-data-[disabled=true]:这是另一种禁用状态,用于当 LabelInput 被包裹在一个共同的父元素(如 FormItem)中,并且该父元素被标记为 data-disabled=true 时。

9.4.3. 编码实现:重构 label.tsx

现在我们重构组件主体,使其导入 labelVariants,并基于 Radix UILabelPrimitive 构建。

文件路径src/components/ui/label/label.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
import * as React from "react";
// [1] 导入 Radix UI 的 Label 原语
import * as LabelPrimitive from "@radix-ui/react-label";
import type { VariantProps } from "class-variance-authority";

import { cn } from "@/utils/cn";
import { labelVariants } from "./label.variants";

// [2] 定义 Props,继承 Radix Label 的所有属性
export interface LabelProps
extends React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>,
VariantProps<typeof labelVariants> {}

// [3] 使用 forwardRef 将 ref 传递给 Radix 原语
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
LabelProps
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
data-slot="label" // [4] 添加 data-slot
className={cn(labelVariants({ className }))} // [5] 应用 cva 样式
{...props}
/>
));
Label.displayName = LabelPrimitive.Root.displayName; // [6] 继承 Radix 的 displayName

export { Label };

架构深度解析

  1. @radix-ui/react-label:我们封装的 不是 原生的 <label> 标签,而是 Radix UI 的 LabelPrimitive.Root
  2. 为什么选择 Radix? Radix Label 解决了原生 <label> 的一个痛点:当用户点击 Label 时,它不仅会聚焦(focus)到关联的 Input,还会在某些浏览器上触发一次额外的点击(click)事件。Radix Label 阻止了这种默认行为,确保点击 Label 只会 触发 focus,提供了更可预测的交互。
  3. Ref 传递:我们使用 React.forwardRef 并正确键入了 ElementRef,这允许父组件获取对 Radix Label DOM 节点的引用。
  4. displayName:我们直接从 LabelPrimitive.Root.displayName 继承 displayName,这是封装 Radix 组件的最佳实践。

9.4.4. CDD 实践:验证 LabelInput 的联动

Label 组件的真正价值在于它与 Input交互。我们将在 Storybook 中创建一个 联合故事 来验证这种可访问性 (a11y) 行为,并使用 play 函数来自动化测试它。

文件路径src/components/ui/label.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
import type { Meta, StoryObj } from '@storybook/react';
import { Label } from './label';
import { Input } from './input'; // [1] 导入 Input 组件
import { fn, userEvent, within, expect } from '@storybook/test'; // [2] 导入测试工具

const meta: Meta<typeof Label> = {
title: 'UI/Label',
component: Label,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
};

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

// [3] 基础故事
export const Default: Story = {
args: {
children: '这是一个 Label',
},
};

// [4] 关键的联合故事:测试 A11y 交互
export const WithInput: StoryObj<typeof meta> = {
name: 'A11y: Click to Focus Input',
// [5] 渲染一个 Label 和一个 Input,并通过 ID 关联
render: (args) => (
<div className="flex flex-col items-center gap-2">
<Label htmlFor="email-input" {...args}>
邮箱地址
</Label>
<Input type="email" id="email-input" placeholder="test@example.com" />
</div>
),
args: {},
// [6] 编写 play 函数来自动化测试交互
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);

// [A] 查找元素
const label = canvas.getByText('邮箱地址');
const input = canvas.getByPlaceholderText('test@example.com');

// [B] 初始状态断言:Input 不应该有焦点
await expect(input).not.toHaveFocus();

// [C] 模拟交互:用户点击 Label
await userEvent.click(label);

// [D] 最终状态断言:Input 应该获得焦点
await expect(input).toHaveFocus();
},
};

启动 Storybook

1
pnpm storybook

验证我们的架构

  1. A11y 验证:导航到 UI/Label/A11y: Click to Focus Input 故事。
  2. 手动测试:用鼠标点击 “邮箱地址” 这个 Label。你会发现 Input立即获得了焦点。这就是 htmlFor="email-input"id="email-input" 配合 Radix UI 原语带来的可访问性。
  3. 自动化测试:查看 “Interactions” 选项卡。你会看到 play 函数的每一步都已成功执行:expect(not.toHaveFocus) -> click(label) -> expect(toHaveFocus)
  4. peer-disabled 验证:(可选) 你可以在 WithInput 故事的 render 函数中为 <Input> 添加 disabled 属性,你会立即看到 Label 的样式变为半透明,证明 peer-disabled 样式已生效。

任务 9.4 已完成!

我们成功地将 Label 组件重构为了我们项目的标准架构(variants + component),并使用 Storybook 的 play 函数验证了它与 Input 之间的核心可访问性(a11y)交互。


9.5. 本章小结 & 代码入库

在本章中,我们完成了一次至关重要的架构升级,从第八章的“手工作坊”模式演进到了企业级的“自动化 + 定制化”工作流。我们不仅引入了 shadcn/ui CLI,更关键的是,我们 驯服 了它,使其完美服务于我们已有的、基于 Vanilla Extract 的复杂主题系统。

回顾本章,我们取得了以下核心进展:

  1. 制定了“CSS 桥接”战略 (9.1):我们识别出 shadcn init 默认注入的 HSL/OKLCH 变量与我们 VE 主题的根本冲突。我们制定了“劫持”策略:用我们自己的 VE 令牌(如 var(--colors-palette-primary-default))去 喂给 shadcn 期望的 CSS 变量(如 --primary)。

  2. “驯服”了 shadcn init (9.2):我们精确地执行了 init 命令,正确配置了 components.json 中的别名(@/ui, @/utils),并用我们准备好的“CSS 桥接层”替换shadcn 注入到 src/index.css 的默认变量。同时,我们审查并接受了它对 tailwind.config.ts 的良性扩展。

  3. 确立了组件架构规范 (9.3):在 add input 后,我们 拒绝了默认的“面条码”,而是遵循您制定的规范,将其重构为与 Button 一致的 样式分离架构input.variants.tsx + input.tsx)。

  4. 验证了主题融合 (9.3.5):我们在 Storybook 中通过 CDD 验证了 最终成果:我们定制的 Input 组件,其所有状态(焦点、失效、暗色模式)100% 由我们自己的 VE 主题驱动

  5. 验证了 A11y 交互 (9.4.4):我们用同样的架构重构了 Label 组件,并利用 Storybook 的 play 函数自动化测试了它与 Input 之间的 可访问性(a11y)联动


代码入库:shadcn/ui 模式落地

我们已经完成了 shadcn/ui 的初始化和两个核心原子组件的集成。这个提交标志着我们 src/ui 层的开发工作流已正式定型。

第一步:检查代码状态

使用 git status 查看变更。

1
git status

你会看到大量的新增文件和修改:

  • 新增 (New):
    • components.json (shadcn 的“大脑”)
    • src/components/ui/input/ (包含 input.tsxinput.variants.tsx)
    • src/components/ui/input/input.stories.tsx
    • src/components/ui/label/ (包含 label.tsxlabel.variants.tsx)
    • src/components/ui/label.stories.tsx
  • 修改 (Modified):
    • src/index.css (注入了我们完整的“CSS 桥接层”)
    • tailwind.config.ts (被 init 自动扩展了 themeplugins)
    • src/utils/cn.ts (被 init 覆盖为类型更安全的版本)
    • package.json / pnpm-lock.yaml (新增了 @radix-ui/react-label, tailwindcss-animate 等依赖)

第二步:暂存所有变更

1
git add .

第三步:执行提交

我们编写一条符合 “约定式提交” 规范的 Commit Message。这是一个重大的功能(feat),范围是 ui

1
git commit -m "feat(ui): init shadcn/ui and build CSS bridge adapter"