第五章. shadcn 核心工作流:从 Input 原语到业务组件的封装
本章目标: 本章将是 shadcn 的主场。我们将学习 shadcn 最核心的工作流——使用 add 命令来获取组件 原语 (Primitive)。然后,我们将以 Ant Design 成熟的 Input 组件作为我们汲取灵感和需求的“参照系”,学习如何从一个高质量的基础原语出发,结合常见的业务场景,逐步封装和增强,最终打造出 真正适合我们 Prorise UI 设计系统的、功能丰富的 Input 组件。
这个过程,将完美诠释 shadcn “为我所有,随我所改”的哲学精髓。
我们封装的第一步,不是从零编写,而是从 shadcn 的“组件注册表”中,获取一个高质量的、遵循了我们项目所有规范的起点。
在项目根目录下,执行以下命令,分别获取 Input 和 Label 组件的源代码。
1 2
| npx shadcn@latest add input
|
1 2
| npx shadcn@latest add label
|
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:... 这一行!shadcn 的 Input 内置了对 可访问性属性 的样式响应。当 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,根据其关联 Input 的 disabled 状态自动更新,无需任何 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 的所有功能,而是学习它的设计思路,然后 按需实现 我们自己版本。这正是“封装适合自己的组件”的精髓所在。
现在,我们开始对 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';
export interface InputProps extends React.InputHTMLAttributes<HTMLElement> {}
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. 功能增强一:实现 prefix 与 suffix 图标
为了容纳 prefix 和 suffix,我们需要一个 <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';
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> { prefix?: string; suffix?: string; }
const Input = React.forwardRef<HTMLInputElement, InputProps>( ({ className, type, prefix, suffix, ...props }, ref) => { 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
|
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) => { const showClear = allowClear && value && String(value).length > 0;
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 };
|
解析:
我们增加了 allowClear 和 onClear 两个 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 来驱动和控制。
工作机制:
- 父组件在
state 中(通常使用 useState)维护输入框的值。 - 父组件将这个
state 值通过 value prop 传递给 <Input /> 组件。 - 父组件将一个能够更新
state 的函数(如 setValue)通过 onChange prop 传递给 <Input /> 组件。 - 当用户在输入框中键入时,
<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 状态管理。
工作机制:
- 我们 不 为
<Input /> 传递 value prop。 - 我们可以通过
defaultValue prop 设置其初始值。 - 当我们需要读取输入框的值时(例如,在表单提交时),我们通过
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'; import { Button } from '@/components/ui/Button';
function UncontrolledExample() { const inputRef = useRef<HTMLInputElement>(null);
const handleSubmit = () => { 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-form 和 Zod 的结合使用,来学习构建复杂表单的终极解决方案。
最终结论:
默认选择受控组件,因为它提供了最强的灵活性和最清晰的数据流,能够满足绝大部分现代 UI 的交互需求。只有在确认是极简的“一次性”表单,且性能成为瓶颈时,才考虑使用非受控组件。对于任何中等及以上复杂度的表单,都应该优先考虑使用专业的表单库。
5.5. 流程闭环:为增强型 Input 编写 Story 与文档
至此,我们已经完成了 Input 组件的功能增强,并深入理解了其背后的“受控”与“非受控”设计模式。现在,我们必须完成本章“黄金工作流”的最后一步:为我们这个全新的、更强大的 Input 组件,创建一套能够完整展示其所有功能的专业 Storybook 文档。
第一步:创建 Story 文件
如果 src/components/ui 目录下已存在 Input.stories.tsx (可能由 add 命令的早期版本创建),请先清空其内容。如果不存在,则创建它。
1
| touch src/components/ui/Input.stories.tsx
|
我们的第一项任务是在 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'], argTypes: { prefix: { control: 'text', description: '输入框前缀图标的名称 (例如: "lucide:search")', }, suffix: { control: 'text', description: '输入框后缀图标的名称 (例如: "lucide:calendar")', }, allowClear: { control: 'boolean', description: '是否启用一键清除功能 (需要以受控模式使用)', }, disabled: { control: 'boolean', description: '是否禁用输入框', }, 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'; import type { Meta, StoryObj } from '@storybook/nextjs-vite'; import { Input } from './Input';
const meta: Meta<typeof Input> = { }; export default meta; type Story = StoryObj<typeof meta>;
export const Default: Story = { args: { placeholder: '请输入内容...', }, };
export const WithPrefix: Story = { args: { placeholder: '搜索...', prefix: 'lucide:search', }, };
export const WithSuffix: Story = { args: { placeholder: '选择日期', suffix: 'lucide:calendar', type: 'date', }, };
export const WithClearButton: Story = { render: function Render(args) { const [value, setValue] = React.useState('这是一段可以被清除的文本');
return ( <Input {...args} value={value} onChange={(e) => setValue(e.target.value)} onClear={() => setValue('')} // 将 onClear 回调连接到状态更新 /> ); }, name: '可清除 (受控模式)', args: { allowClear: true, className: 'w-80', }, };
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 组件。我们将 value 和 onChange 传递给 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 的核心哲学。
shadcn 核心工作流: 我们学习了使用 npx shadcn@latest add 命令来获取高质量的组件原语,并对生成的代码进行了深度“考古”,理解了其背后蕴含的 aria-invalid、peer-disabled 等高级实践。- 业务驱动的封装: 我们以
Ant Design 为参照,学习了如何分析真实业务场景,从而确定我们对基础原语的增强方向。 - 渐进式增强: 我们亲手为
Input 原语添加了 prefix/suffix 和 allowClear 等功能,掌握了从一个简单组件逐步演进为一个复杂组件的封装技巧。 - 核心模式辨析: 通过
allowClear 功能的实现,我们深入探讨了 React 中受控与非受控组件的本质区别,并建立了一套清晰的状态管理决策模型。 - 黄金工作流闭环: 最后,我们为这个全新的、功能更强大的
Input 组件,创建了完整的 Storybook 文档,再一次巩固了“开发即文档”的专业工作流。
通过本章,您掌握的已不仅仅是一个 Input 组件,而是一套完整的、从获取开源方案、分析业务需求、进行二次封装、再到沉淀为文档的方法论。这是构建任何复杂设计系统的核心能力。