React组件库实战 - 第五章. shadcn/ui 工作流:从 Input 原语到业务组件的封装

第五章. shadcn 核心工作流:从 Input 原语到业务组件的封装

本章目标: 本章将是 shadcn 的主场。我们将学习 shadcn 最核心的工作流——使用 add 命令来获取组件 原语 (Primitive)。然后,我们将以 Ant Design 成熟的 Input 组件作为我们汲取灵感和需求的“参照系”,学习如何从一个高质量的基础原语出发,结合常见的业务场景,逐步封装和增强,最终打造出 真正适合我们 Prorise UI 设计系统的、功能丰富的 Input 组件

这个过程,将完美诠释 shadcn “为我所有,随我所改”的哲学精髓。

5.1. 奠定基石:通过 add 命令获取 InputLabel 原语

我们封装的第一步,不是从零编写,而是从 shadcn 的“组件注册表”中,获取一个高质量的、遵循了我们项目所有规范的起点。

5.1.1. add 命令实战:获取 InputLabel

在项目根目录下,执行以下命令,分别获取 InputLabel 组件的源代码。

1
2
# 获取 Input 组件原语
npx shadcn@latest add input
1
2
# 获取 Label 组件原语
npx shadcn@latest add label

5.1.2. 代码考古:剖析生成的 Input.tsxLabel.tsx

add 命令执行完毕后,我们的 src/components/ui 目录下会新增两个文件。现在,让我们对这份由官方工具生成的、真实的代码进行一次“X 光”级别的深度剖析。

文件路径: src/components/ui/Input.tsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import * as React from "react"

import { cn } from "@/lib/utils"

function Input({ className, type, ...props }: React.ComponentProps<"input">) {
  return (
    <input
      type={type}
      data-slot="input"
      className={cn(
        "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
        "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
        "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
        className
      )}
      {...props}
    />
  )
}

export { Input }

Input.tsx 核心洞察:
这不仅仅是一个简单的样式化包装器,它是一个 高度工程化的“原语”

  • aria-invalid 驱动的样式: 注意 aria-invalid:... 这一行!shadcnInput 内置了对 可访问性属性 的样式响应。当 Input 因校验失败而被外部逻辑(如表单库)设置了 aria-invalid="true" 属性时,它的边框和 ring 颜色会自动变为“破坏性”颜色(红色)。这是一种极其现代和优雅的实践,将校验状态的视觉反馈与可访问性标准紧密绑定。
  • 精细的伪元素样式: 它通过 file:placeholder:selection: 等 Tailwind 伪元素选择器,对输入框的每一个细节都进行了精心的样式化。
  • focus-visible: 它使用了 focus-visible 而不是 focus,这意味着只有在通过键盘 (Tab) 聚焦时,才会显示醒目的 ring 效果,而鼠标点击聚焦则不会,这提供了更自然的用户体验。
  • 简洁的 Props 类型: 使用 React.ComponentProps<"input"> 直接获取原生 input 标签的所有属性类型,简洁高效。

文件路径: src/components/ui/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
"use client"

import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"

import { cn } from "@/lib/utils"

const Label = ({
  className,
  ...props
}: React.ComponentProps<typeof LabelPrimitive.Root>) => {
  return (
    <LabelPrimitive.Root
      data-slot="label"
      className={cn(
        "text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
        className
      )}
      {...props}
    />
  )
}

export { Label }

Label.tsx 核心洞察:

  • 基于 Radix: 它印证了 shadcn 的核心理念——Label 的行为和可访问性完全委托给了 @radix-ui/react-label 这个专业的“无头”原语。
  • peer-disabled: 这是 Tailwind CSS 一个极其巧妙的特性。peer 类通常被添加到兄弟元素上(例如,我们可以在 <input> 上添加 peer 类)。peer-disabled:opacity-50 的意思是:“如果我的‘同伴’(peer)处于 disabled 状态,那么就把我(Label)的透明度设为 50%。” 这使得 Label 的禁用样式能够 仅通过 CSS,根据其关联 Inputdisabled 状态自动更新,无需任何 JavaScript 逻辑。这是现代 CSS 驱动的响应式 UI 的绝佳范例。

结论: shadcn add 命令为我们提供的,是蕴含了大量前端最佳实践、高度凝练的组件原语。我们的任务,是在这个坚实的起点上进行扩展。

5.2. 需求分析:从 Ant Design 汲取封装灵感

一个裸的 Input 原语在真实业务中是远远不够的。用户需要引导、需要反馈、需要便捷的操作。Ant Design 的 Input API 为我们展示了一个功能完备的输入框应该具备哪些能力。

我们将从中汲取灵感,挑选出几个最常见、最有价值的功能点,作为我们 Prorise UI Input 的封装目标:

功能点 (启发自 Ant Design)业务场景
prefix / suffix在输入框内前置或后置图标,如搜索图标、金额符号 ¥ 等。
allowClear提供一个“一键清除”按钮,在输入框有内容时自动显示。

核心思想: 我们 追求 1:1 复刻 antd 的所有功能,而是学习它的设计思路,然后 按需实现 我们自己版本。这正是“封装适合自己的组件”的精髓所在。

5.3. 渐进式封装:构建 Prorise UI 的增强型 Input

现在,我们开始对 shadcn 为我们生成的 Input.tsx 进行“魔改”,逐步为其添加新功能。

5.3.1. 最佳实践升级:添加 forwardRef

shadcn 生成的 Input 是一个简单函数组件,不支持 ref 转发。在一个专业的设计系统中,允许父组件获取对底层 input DOM 的引用(例如,用于手动聚焦)是一项必备功能。因此,我们的第一步是为其升级,添加 React.forwardRef

文件路径: src/components/ui/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
28
29
import * as React from 'react';

import { cn } from '@/lib/utils';
// 1. 扩展原始的 Props 接口
export interface InputProps extends React.InputHTMLAttributes<HTMLElement> {}


// 2. 使用 forwardRef 创建组件,forwardRef接收两个参数,第一个是 ref 的类型,第二个是 props 的类型
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
data-slot="input"
ref={ref}
className={cn(
'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]',
'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive',
className
)}
{...props}
/>
);
}
);

export { Input };

解析: 通过 React.forwardRef 的包裹,我们的 Input 组件现在可以接受一个 ref prop,并将其正确地传递给底层的 <input> 元素,使其具备了完整的可控性。

5.3.2. 功能增强一:实现 prefixsuffix 图标

为了容纳 prefixsuffix,我们需要一个 <div> 作为外层容器,并使用 Flex 布局。

文件路径: src/components/ui/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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
import * as React from 'react';
import { cn } from '@/lib/utils';
import { Icon } from '@/components/ui/Icon';

// 1. 扩展 Props 接口
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
prefix?: string;
suffix?: string;
}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, prefix, suffix, ...props }, ref) => {
// 2. 总是渲染带容器的结构,以统一 DOM 结构和样式行为
return (
<div
className={cn(
// 将原 input 的大部分外观样式移到外层 div
'flex h-9 items-center rounded-md border border-input bg-transparent px-3 text-sm shadow-xs ring-offset-background transition-[color,box-shadow]',
// 处理焦点状态
'focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50',
// 处理校验失败状态
'aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40',
// 处理禁用状态
props.disabled && 'cursor-not-allowed opacity-50'
)}
>
{prefix && <Icon name={prefix} className="mr-2 h-4 w-4 text-muted-foreground" />}
<input
type={type}
// 3. 重置内部 input 的样式让它"隐形"在容器中
className={cn(
'h-full w-full flex-1 bg-transparent p-0 text-base placeholder:text-muted-foreground outline-none disabled:cursor-not-allowed md:text-sm',
className // 外部 className 应用在这里
)}
ref={ref}
{...props}
/>
{suffix && <Icon name={suffix} className="ml-2 h-4 w-4 text-muted-foreground" />}
</div>
);
}
);
Input.displayName = 'Input';

export { Input };

解析: 我们不再使用条件渲染,而是 总是渲染div 容器的结构。这是一种更健壮的设计,可以避免因 props 变化导致 DOM 结构突变而引发的意外问题(例如输入框失去焦点)。我们将原 Input 的边框、背景、ring 等样式,都转移到了外层 div 上,并使用了 focus-within 伪类来响应内部 input 的聚焦。

5.3.3. 功能增强二:实现 allowClear 清除按钮

文件路径: src/components/ui/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
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
// ... imports ...

// 1. 扩展 Props 接口
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
prefix?: string;
suffix?: string;
allowClear?: boolean;
onClear?: () => void;
}

const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, prefix, suffix, allowClear, onClear, value, ...props }, ref) => {

// 2. 判断清除按钮是否显示
const showClear = allowClear && value && String(value).length > 0;

// 3. 决定最终的 suffix 内容:优先显示清除按钮
const finalSuffix = showClear ? (
<button
type="button"
onClick={onClear}
className="ml-2 focus:outline-none"
aria-label="Clear input"
>
<Icon name="lucide:x-circle" className="h-4 w-4 text-muted-foreground hover:text-foreground" />
</button>
) : suffix ? (
<Icon name={suffix} className="ml-2 h-4 w-4 text-muted-foreground" />
) : null;

return (
<div /* ... 容器 div 结构 ... */ >
{prefix && <Icon name={prefix} className="mr-2 h-4 w-4 text-muted-foreground" />}
<input
type={type}
className={cn( /* ... 内部 input 样式 ... */ )}
ref={ref}
value={value} // 确保 value 被传递
{...props}
/>
{finalSuffix} {/* <-- 使用 finalSuffix */}
</div>
);
}
);
Input.displayName = 'Input';

export { Input };

解析:
我们增加了 allowClearonClear 两个 prop。组件现在会检查 allowClear 是否为 true 以及 value prop 是否有值,来决定是否渲染一个带清除功能的 <button>。点击这个按钮会触发 onClear 回调。

这个功能的实现,引出了一个至关重要的问题,也是 React 表单处理的核心——受控与非受控模式。为了能判断 value 是否有值,并能通过 onClear 清空它,我们的 Input 组件必须以“受控模式”来工作。这正是我们下一节将要深入探讨的主题。


5.4. 模式再思考:受控与非受控及状态管理决策

在上一节中,我们为 Input 组件成功添加了 allowClear 功能。然而,这个功能的实现(showClear 依赖于 value prop,onClear 回调需要能改变 value)将我们引向了一个无法回避、也至关重要的架构岔路口:输入框的状态(即它的当前值),应该由谁来管理?

这个问题的答案,引出了 React 中处理表单状态的两种核心设计模式——受控组件非受控组件。本节,我们将深入这两种模式,并建立一个清晰的决策模型。

5.4.1. 深度剖析:两种表单状态管理模式

1. 受控组件

核心理念: 在此模式中,React 组件的状态 (state) 成为表单元素值的“单一事实来源”。输入框的当前值完全由 React 的 state 来驱动和控制。

工作机制:

  1. 父组件在 state 中(通常使用 useState)维护输入框的值。
  2. 父组件将这个 state 值通过 value prop 传递给 <Input /> 组件。
  3. 父组件将一个能够更新 state 的函数(如 setValue)通过 onChange prop 传递给 <Input /> 组件。
  4. 当用户在输入框中键入时,<Input /> 组件触发 onChange 事件,调用父组件传递过来的函数来更新 state,从而引发父组件重渲染,并将新的 value 再次传递给 <Input />,完成数据流的闭环。

代码示例:
为了清晰演示,我们可以在 src/app 目录下创建一个新的页面文件。

文件路径: src/app/form-patterns/page.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
'use client';

import { useState } from 'react';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';

function ControlledExample() {
const [value, setValue] = useState('');

const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setValue(event.target.value);
};

return (
<div className="w-full max-w-sm space-y-2">
<h3 className="font-semibold">受控组件示例</h3>
<Label htmlFor="controlled-input">输入:</Label>
<Input
id="controlled-input"
placeholder="值由 React state 控制..."
value={value}
allowClear
onChange={handleChange}
/>
<p className="text-muted-foreground text-sm">
来自 React state 的当前值:{' '}
<span className="text-primary font-mono">{value}</span>
</p>
</div>
);
}

export default function FormPatternsPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-12 p-24">
<ControlledExample />
</main>
);
}

解析:
在这个模式下,Input 组件本身变成了一个“哑”组件。它不自己存储值,只负责忠实地显示父组件通过 value prop 传递过来的值,并在用户输入时,通过 onChange 通知父组件。数据的流动是单向的(父 -> 子),事件的流动也是单向的(子 -> 父)。我们 allowClear 功能之所以能工作,正是因为它依赖于这种父组件对 value 的完全控制。

  • 优点: 单一数据源清晰,状态始终与 React 组件树同步,便于实现实时校验、字符计数、条件禁用按钮等复杂交互。
  • 缺点: 每次输入(onChange)都会触发一次父组件的重渲染。在绝大多数情况下这不是问题,但在极其复杂的、包含大量输入框的表单中,可能会有性能考量。

2. 非受控组件

核心理念: 在此模式中,DOM 节点本身成为表单元素值的“单一事实来源”。React 负责初始渲染,但之后输入框的值由其自己的 DOM 状态管理。

工作机制:

  1. 我们 <Input /> 传递 value prop。
  2. 我们可以通过 defaultValue prop 设置其初始值。
  3. 当我们需要读取输入框的值时(例如,在表单提交时),我们通过 ref 来直接访问底层的 DOM 节点并获取其 value

代码示例:
让我们在 FormPatternsPage 中继续添加非受控组件的例子。

文件路径: src/app/form-patterns/page.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
import { useRef } from 'react'; // 引入 useRef
import { Button } from '@/components/ui/Button';

function UncontrolledExample() {
// 1. 创建一个 ref 来引用 input DOM 元素
const inputRef = useRef<HTMLInputElement>(null);

const handleSubmit = () => {
// 3. 在需要时,通过 ref 直接从 DOM 读取值
alert(`从 DOM 中读取的值: ${inputRef.current?.value}`);
};

return (
<div className="w-full max-w-sm space-y-2">
<h3 className="font-semibold">非受控组件示例</h3>
<Label htmlFor="uncontrolled-input">输入:</Label>
<Input
id="uncontrolled-input"
// 2. ref 传递给 Input 组件
ref={inputRef}
placeholder="值由 DOM 自身管理..."
defaultValue="这是一个初始值"
/>
<Button onClick={handleSubmit} className="mt-2">
提交并读取值
</Button>
</div>
);
}

export default function FormPatternsPage() {
return (
<main className="flex min-h-screen flex-col items-center justify-center gap-12 p-24">
<ControlledExample />
<UncontrolledExample /> {/* <-- 添加新示例 */}
</main>
);
}

解析:
Input 组件的值现在完全由浏览器 DOM 管理。我们只在需要时(点击提交按钮)才通过 ref 去“查询”它的当前值。

  • 优点: 实现简单直接,对于“一次性”读取值的简单表单非常高效。由于输入时不会触发 React 的重渲染,理论上性能更好。
  • 缺点: 数据状态与 React 组件树脱节,难以实现实时交互。像我们之前实现的 allowClear 功能,在这种模式下就变得非常棘手,因为它需要组件能“感知”到自己是否有值。

5.4.2. 最佳实践:建立状态管理决策模型

那么,在实际开发中我们应该如何选择?您可以根据以下决策模型来判断:

问题一:“在用户提交之前,我是否需要知道输入框的实时值?”

  • 如果答案是“是”:

    • 你需要实现 实时输入校验(例如,用户名是否可用)。
    • 你需要实现 实时字符计数
    • 你需要根据一个输入框的值,动态地改变 另一个 UI 元素的状态(例如,输入框有内容时才点亮提交按钮)。
    • => 结论:必须使用受控组件。 这是现代 Web 应用中最常见的情况。
  • 如果答案是“否”:

    • 你只需要一个简单的登录表单、评论框或搜索框。
    • 你只在用户点击“提交”按钮的那一刻才关心输入框的值。
    • => 结论:非受控组件是一个更简单、性能可能更好的选择。

问题二:“这个表单的状态是否非常复杂,或者需要在多个远距离组件间共享?”

  • 如果答案是“是”:
    • 你正在构建一个多步骤的向导式表单。
    • 表单的状态需要影响到页面顶部的导航栏或侧边栏。
    • 你有大量的、相互依赖的动态表单域。
    • => 结论:是时候停止手动管理状态了,应该将状态管理委托给专业的库。

专业方案:

  • 全局状态管理器 (如 Zustand, Redux): 适用于表单状态需要与应用的其他部分进行深度交互的场景。
  • 专业表单库 (如 react-hook-form, formik): 这是 绝大多数复杂表单的最佳实践。这类库在内部通常采用非受控模式以实现极致性能,同时通过 Hooks 提供了完整的、类型安全的状态管理、校验(通常结合 Zod)和提交处理能力,极大地减少了模板代码。

我们将在第九章中,专门深入 react-hook-formZod 的结合使用,来学习构建复杂表单的终极解决方案。

最终结论:
默认选择受控组件,因为它提供了最强的灵活性和最清晰的数据流,能够满足绝大部分现代 UI 的交互需求。只有在确认是极简的“一次性”表单,且性能成为瓶颈时,才考虑使用非受控组件。对于任何中等及以上复杂度的表单,都应该优先考虑使用专业的表单库。


5.5. 流程闭环:为增强型 Input 编写 Story 与文档

至此,我们已经完成了 Input 组件的功能增强,并深入理解了其背后的“受控”与“非受控”设计模式。现在,我们必须完成本章“黄金工作流”的最后一步:为我们这个全新的、更强大的 Input 组件,创建一套能够完整展示其所有功能的专业 Storybook 文档。

第一步:创建 Story 文件

如果 src/components/ui 目录下已存在 Input.stories.tsx (可能由 add 命令的早期版本创建),请先清空其内容。如果不存在,则创建它。

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

第二步:更新 Meta 对象以反映新 Props

我们的第一项任务是在 Story 文件中定义 meta 对象。我们将特别为新增的 prefix, suffix, allowClear 等 props 添加详尽的文档描述和交互控件。

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

const meta: Meta<typeof Input> = {
title: 'UI/Input',
component: Input,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
// 为我们的 props 配置交互式控件和文档
argTypes: {
prefix: {
control: 'text',
description: '输入框前缀图标的名称 (例如: "lucide:search")',
},
suffix: {
control: 'text',
description: '输入框后缀图标的名称 (例如: "lucide:calendar")',
},
allowClear: {
control: 'boolean',
description: '是否启用一键清除功能 (需要以受控模式使用)',
},
disabled: {
control: 'boolean',
description: '是否禁用输入框',
},
// 将 onClear 标记为一个 action,以便在 Storybook 的 Actions 面板中追踪其调用
onClear: { action: 'onClear' },
},
};

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

// 后续的故事定义将写在这里...

解析: 我们通过 argTypes 详细配置了每个重要 prop 在 Controls 面板中的行为和描述。特别地,为 onClear 设置 action: 'cleared',可以让 Storybook 自动“监听”这个回调函数,每当它被触发时,都会在 “Actions” 面板中打印一条日志,这对于调试交互非常有用。

第三步:编写展示新功能的 Stories

现在,我们来创建一系列的故事,分别展示 Input 组件的核心功能和不同状态。

文件路径: 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
58
59
60
61
62
63
64
65
import * as React from 'react'; // 引入 React 用于受控组件的 Story
import type { Meta, StoryObj } from '@storybook/nextjs-vite';
import { Input } from './Input';

// ... 此前定义的 meta 对象 ...
const meta: Meta<typeof Input> = { /* ... */ };
export default meta;
type Story = StoryObj<typeof meta>;


// 1. 基础的、可交互的 Story
export const Default: Story = {
args: {
placeholder: '请输入内容...',
},
};

// 2. 带前缀图标的 Story
export const WithPrefix: Story = {
args: {
placeholder: '搜索...',
prefix: 'lucide:search',
},
};

// 3. 带后缀图标的 Story
export const WithSuffix: Story = {
args: {
placeholder: '选择日期',
suffix: 'lucide:calendar',
type: 'date',
},
};

// 4. (关键) 演示 allowClear 功能的受控 Story
export const WithClearButton: Story = {
// 我们必须使用 render 函数来模拟一个受控组件的父级环境
render: function Render(args) {
// 使用 React Hook 来管理 value 状态
const [value, setValue] = React.useState('这是一段可以被清除的文本');

return (
<Input
{...args}
value={value}
onChange={(e) => setValue(e.target.value)}
onClear={() => setValue('')} // 将 onClear 回调连接到状态更新
/>
);
},
name: '可清除 (受控模式)', // 为 Story 设置一个更清晰的显示名称
args: {
allowClear: true,
className: 'w-80', // 给一个更宽的尺寸以便展示
},
};

// 5. 禁用状态的 Story
export const Disabled: Story = {
args: {
placeholder: '此输入框已被禁用',
prefix: 'lucide:lock',
disabled: true,
}
}

代码深度解析:

  • 我们为每个核心功能(prefix, suffix, disabled)都创建了清晰、独立的 Story。
  • WithClearButton Story 是本节的重点。由于 allowClear 功能依赖于“受控模式”,我们不能简单地在 args 中提供一个静态的 value。因此,我们使用了 render 函数,在 Story 内部创建了一个小型的 React 环境,使用 useState 来真正地“控制”Input 组件。我们将 valueonChange 传递给 Input,并将 onClear 回调与 setValue('') 绑定。这不仅完美地演示了 allowClear 的功能,更是在 Storybook 的上下文中,对上一节学习的“受控组件”理论进行了一次绝佳的实战演练。

第四步:创建 MDX 文档

最后,我们创建 Input.mdx 文件,将所有内容整合到一份专业的文档中。

文件路径: src/components/ui/Input.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
38
39
40
41
42
43
44
45
import { Meta, Story, Controls } from '@storybook/addon-docs';
import * as InputStories from './Input.stories';

<Meta of={InputStories} />

# Input (输入框)

输入框是我们设计系统中最基础的表单域包装器,用于通过鼠标或键盘输入内容。

## 设计哲学

我们的 `Input` 组件始于 `shadcn` 提供的、遵循最佳实践的高质量原语。在此基础上,我们借鉴了 Ant Design 等成熟组件库的设计经验,为其逐步封装了 `prefix`/`suffix` 图标、`allowClear` 清除按钮等在真实业务场景中常见的功能。

这种“从原语启动,按需增强”的模式,是我们 `Prorise UI` 的核心封装理念。

## 交互式示例

使用下方的 `Default` Story 来探索 `Input` 组件的所有可用 props。

<Story of={InputStories.Default} />

## 功能展示

### 前后缀图标

通过 `prefix` 或 `suffix` 属性,可以轻松地在输入框内部添加引导性图标。属性的值应为一个 `Iconify` 格式的图标名称字符串。

<Story of={InputStories.WithPrefix} />
<br />
<Story of={InputStories.WithSuffix} />

### 可清除内容

当 `allowClear` 属性为 `true` 时,输入框会在有内容时显示一个清除按钮。请注意,此功能要求您以 **受控模式** 来使用 `Input` 组件,即同时提供 `value` 和 `onChange` 属性,并将 `onClear` 回调用于清空您的状态。

<Story of={InputStories.WithClearButton} />

### 禁用状态

<Story of={InputStories.Disabled} />


## API 参考 (Props)

<Controls />

最终验证

运行 pnpm storybook 并导航到 UI/Input。现在,Input 组件拥有了内容丰富、示例清晰、API 文档完整的专业文档页。我们为 Input 组件的开发工作,画上了一个圆满的句号。


5.6 本章小结

在本章中,我们完成了一次从“获取”到“创造”的完整旅程,深刻践行了 shadcn 的核心哲学。

  1. shadcn 核心工作流: 我们学习了使用 npx shadcn@latest add 命令来获取高质量的组件原语,并对生成的代码进行了深度“考古”,理解了其背后蕴含的 aria-invalidpeer-disabled 等高级实践。
  2. 业务驱动的封装: 我们以 Ant Design 为参照,学习了如何分析真实业务场景,从而确定我们对基础原语的增强方向。
  3. 渐进式增强: 我们亲手为 Input 原语添加了 prefix/suffixallowClear 等功能,掌握了从一个简单组件逐步演进为一个复杂组件的封装技巧。
  4. 核心模式辨析: 通过 allowClear 功能的实现,我们深入探讨了 React 中受控与非受控组件的本质区别,并建立了一套清晰的状态管理决策模型。
  5. 黄金工作流闭环: 最后,我们为这个全新的、功能更强大的 Input 组件,创建了完整的 Storybook 文档,再一次巩固了“开发即文档”的专业工作流。

通过本章,您掌握的已不仅仅是一个 Input 组件,而是一套完整的、从获取开源方案、分析业务需求、进行二次封装、再到沉淀为文档的方法论。这是构建任何复杂设计系统的核心能力。