# 新增一个组件 · 操作手册 > 配套:[DESIGN.md](../DESIGN.md)(规则)、[COMPONENTS.md](COMPONENTS.md)(目录与契约) > 目标:任何人(或 AI)只读这三份文档,就能补出一个风格、API、文档都与现有组件一致的组件。 --- ## 0. 先判断:该不该进库 三个问题**全部为「是」**才进库: 1. **通用吗**:换一个页面、换一个应用还用得上? 2. **无业务吗**:不需要请求接口、不需要认识 `Category` / `entry` 这类业务字段? 3. **组合不出来吗**:现有组件拼不出来?只是「拼起来麻烦一点」不算。 对应处理: | 判断结果 | 处理 | | --- | --- | | 全「是」 | 进库,走下面的流程 | | 业务相关 | 放应用里(页面层),用现有组件拼;必要时在 COMPONENTS.md §4 记一条组合模式 | | 组合能解决 | 不进库,把组合写进展示页 `#composition` | --- ## 1. 流程总览(12 步) | # | 动作 | 产物 | 卡点 | | --- | --- | --- | --- | | 1 | 在 COMPONENTS.md 登记 | 组件地图一行 + §3 契约草案(状态 🚧) | 没登记不开工 | | 2 | 定层级与命名 | 决定 L1–L4 与组件名 | 参照 DESIGN §2 | | 3 | 设计 API | props 表(含类型、默认值) | 命名必须符合 DESIGN §5.2 | | 4 | 写组件 | `src/components/Xxx.tsx` | 透传 rest、`type="button"`、className 合并 | | 5 | 写样式 | `src/components/Xxx.module.css` | 只用令牌;覆盖状态矩阵 | | 6 | 导出 | `src/index.ts` | 组件 + `export type` | | 7 | 展示页 | `site/sections/Xxx.tsx` + App.tsx 挂载 | 变体 / 状态 / props / 代码 | | 8 | 导航与清单 | `site/App.tsx` 的 NAV、README 组件表 | 三处同步(还有契约) | | 9 | 契约定稿 | COMPONENTS.md 状态改 ✅ | 与代码逐字对一遍 | | 10 | 验收 | DESIGN §6 清单逐条打勾 | 有一条不过就不提交 | | 11 | 截图核对 | `shots/` 下的截图 | 看 hover / disabled / 边界 | | 12 | 提交 | `feat(ui): add Xxx` | 一个组件一个提交 | --- ## 2. 模板 ### 2.1 组件 TSX ```tsx import type { HTMLAttributes, ReactNode } from 'react' import styles from './Xxx.module.css' /** * 一句话说清它是什么。 * * 第二段写「边界」:不该用在哪、和相邻组件的区别。 */ export type XxxVariant = 'primary' | 'secondary' export type XxxProps = { /** 语义变体 */ variant?: XxxVariant /** 尺寸档位 */ size?: 'sm' | 'md' children?: ReactNode } & HTMLAttributes // 透传原生属性 export function Xxx({ variant = 'primary', size = 'md', children, className, ...rest }: XxxProps) { // 类名合并:固定顺序 root → 修饰 → 外部传入 const classes = [styles.root, styles[variant], styles[size], className].filter(Boolean).join(' ') return (
{children}
) } ``` 要点: - `className` / `style` 落在**最外层**; - 变体用 `styles[variant]` 映射,不写 if 分支拼类名; - 按钮类组件:`type` 默认 `"button"`,`disabled` 与 `loading` 一起处理; - 受控组件不维护业务状态(`Avatar` 的图片加载失败属于 UI 状态,可以留)。 ### 2.2 样式 module ```css /* 组件专属尺寸用局部变量;颜色 / 字号 / 间距一律全局令牌 */ .root { --xxx-size: 36px; display: inline-flex; align-items: center; gap: var(--su-space-2); height: var(--xxx-size); padding: 0 var(--su-space-3); border-radius: var(--su-radius-sm); font-size: var(--su-font-size-md); color: var(--su-text-main); transition: background-color var(--su-duration-fast) var(--su-ease-out); } /* 变体 */ .primary { background-color: var(--su-color-primary); color: var(--su-white); } .secondary { background-color: var(--su-gray-2); color: var(--su-text-main); } /* 尺寸 */ .sm { --xxx-size: var(--su-control-height-sm); font-size: var(--su-font-size-sm); } /* 状态:hover / disabled / focus-visible 见 DESIGN §4.3 */ .root:hover:not(:disabled) { background-color: var(--su-gray-3); } .root:disabled { cursor: not-allowed; opacity: 0.5; } ``` ### 2.3 展示页 section ```tsx import { Xxx } from '../../src' import { Demo } from '../components/Demo' import { PropsTable } from '../components/PropsTable' import { Section } from '../components/Section' export function XxxSection() { return (
内容`} > 内容 默认 禁用
) } ``` ### 2.4 契约(贴进 COMPONENTS.md §3) 见 [COMPONENTS.md §8](COMPONENTS.md) 的模板。 --- ## 3. 完整示例:`Spinner` 假设要补 COMPONENTS.md §5 里的 P2 组件 `Spinner`,从头到尾就是这样。 ### 3.1 登记(第 1 步) 在 COMPONENTS.md §1 加一行: | 组件 | 层 | 状态 | 一句话 | 关键 props | | --- | --- | --- | --- | --- | | `Spinner` | L4 | 🚧 | 独立加载指示 | `size` `label` | ### 3.2 组件(第 4 步) `src/components/Spinner.tsx`: ```tsx import type { HTMLAttributes } from 'react' import styles from './Spinner.module.css' /** * 加载指示:一个旋转的圆环。 * * 按钮内部的 loading 由 Button 自己处理; * 这个组件用于「整块区域正在加载」——列表首屏、面板刷新。 * 一行文字的占位请用 Skeleton,不要用 Spinner。 */ export type SpinnerProps = { size?: 'sm' | 'md' /** 无障碍名称,屏幕阅读器会读出来 */ label?: string } & HTMLAttributes export function Spinner({ size = 'md', label = '加载中', className, ...rest }: SpinnerProps) { const classes = [styles.root, styles[size], className].filter(Boolean).join(' ') return } ``` ### 3.3 样式(第 5 步) `src/components/Spinner.module.css`: ```css .root { --spinner-size: 20px; --spinner-border: 2px; display: inline-block; width: var(--spinner-size); height: var(--spinner-size); border: var(--spinner-border) solid currentColor; border-top-color: transparent; border-radius: var(--su-radius-pill); color: var(--su-color-primary); animation: spin 0.8s linear infinite; } .sm { --spinner-size: 14px; --spinner-border: 2px; } .md { --spinner-size: 20px; } /* 尊重系统的「减少动态效果」:放慢而不是停住(停住会看起来像坏掉了) */ @media (prefers-reduced-motion: reduce) { .root { animation-duration: 2.4s; } } @keyframes spin { to { transform: rotate(360deg); } } ``` ### 3.4 导出(第 6 步) `src/index.ts`: ```ts export { Spinner } from './components/Spinner' export type { SpinnerProps } from './components/Spinner' ``` ### 3.5 展示页(第 7 步) `site/sections/Spinners.tsx`: ```tsx import { Spinner } from '../../src' import { Demo } from '../components/Demo' import { PropsTable } from '../components/PropsTable' import { Section } from '../components/Section' export function Spinners() { return (
\n`}> `}> 正在读取内容库…
) } ``` `site/App.tsx`:在「交互」组里加 `{ id: 'spinner', label: '加载中 Spinner' }`,并在 `` 后挂 ``。 ### 3.6 契约(第 9 步) ```markdown ### Spinner ✅ - **用途**:整块区域正在加载(列表首屏、面板刷新)。 - **不该用在哪**:按钮内部(用 `Button loading`)、文字占位(用 `Skeleton`)。 - **解剖**:单个 span,`role="status"` + `aria-label`,纯 CSS 圆环。 - **Props**:`size: 'sm' | 'md'`(14 / 20)、`label`(默认「加载中」)。 - **规则**:颜色继承 `currentColor`;`prefers-reduced-motion` 下放慢到 2.4s,不停住。 - **展示页**:`#spinner` ``` ### 3.7 验收与截图(第 10–11 步) ```bash npm run typecheck && npm run lint && npm run build && npm run build:site npm run dev # 另开一个终端 node scripts/screenshot.mjs http://127.0.0.1:5199/ shots/spinner.png '#spinner' ``` 对着截图确认:尺寸档位一致、颜色是主色、没有布局抖动、深色底不破(把背景改成 `--su-gray-8` 试一下)。 ### 3.8 提交(第 12 步) ```text feat(ui): add Spinner - 新增加载指示组件(sm/md,role=status + aria-label) - 展示页补一节;COMPONENTS.md 契约与 README 清单同步 ``` --- ## 4. 常见错误(都踩过或差点踩) | 现象 | 原因 | 正确做法 | | --- | --- | --- | | 换主题后组件颜色不变 | CSS 里写死了 `#fff` / `rgba(...)` | 一律 `var(--su-*)`;颜色不够就去 tokens.css 补令牌 | | 组件在页面里位置飘 | 组件自带 margin | 组件不带外边距,间距交给 `Space` / 父容器 | | 并排的按钮/输入框高低不齐 | 高度写死且不一致 | 用 `--su-control-height-sm/md` | | 图标按钮读不出来 | 只有图标没有名称 | `IconButton` 的 `label` 必填 | | 表单里点按钮页面刷新 | 没写 `type` | 默认 `type="button"` | | 行式条目看起来「一块一块」 | 给列表行套了 `Card` | 列表行不加底色/边框/阴影 | | 比例封面被裁掉一截 | 外层是 `flex-direction: column` | 换 grid / block 容器(DESIGN §9) | | 生产构建图标全空 | 展示站没有显式引入 `iconfont.css` | `site/main.tsx` 显式 import 两个 theme CSS | | 展示页 demo 和真实页面不一致 | demo 里写了一份「演示版」 | demo 必须直接用组件,只喂数据 | | 组件里出现 `categoryId` | 把业务塞进了库 | 业务字段留在页面层 | --- ## 5. 评审时的三句话 1. **够通用吗?**(不是业务组件、不是一次性布局) 2. **只用了令牌吗?**(颜色/字号/间距没有字面量) 3. **文档齐了吗?**(展示页一节 + 契约 + 清单 + 导航) 三条都过,就可以合并。