11 KiB
11 KiB
新增一个组件 · 操作手册
配套:DESIGN.md(规则)、COMPONENTS.md(目录与契约) 目标:任何人(或 AI)只读这三份文档,就能补出一个风格、API、文档都与现有组件一致的组件。
0. 先判断:该不该进库
三个问题**全部为「是」**才进库:
- 通用吗:换一个页面、换一个应用还用得上?
- 无业务吗:不需要请求接口、不需要认识
Category/entry这类业务字段? - 组合不出来吗:现有组件拼不出来?只是「拼起来麻烦一点」不算。
对应处理:
| 判断结果 | 处理 |
|---|---|
| 全「是」 | 进库,走下面的流程 |
| 业务相关 | 放应用里(页面层),用现有组件拼;必要时在 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
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<HTMLDivElement> // 透传原生属性
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 (
<div className={classes} {...rest}>
{children}
</div>
)
}
要点:
className/style落在最外层;- 变体用
styles[variant]映射,不写 if 分支拼类名; - 按钮类组件:
type默认"button",disabled与loading一起处理; - 受控组件不维护业务状态(
Avatar的图片加载失败属于 UI 状态,可以留)。
2.2 样式 module
/* 组件专属尺寸用局部变量;颜色 / 字号 / 间距一律全局令牌 */
.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
import { Xxx } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function XxxSection() {
return (
<Section id="xxx" title="组件名" api="Xxx" desc="一句话说明它的职责和边界。">
<Demo
title="基础用法"
desc="说明这个变体适合什么场景。"
code={`<Xxx variant="primary">内容</Xxx>`}
>
<Xxx>内容</Xxx>
</Demo>
<Demo title="状态" desc="把 hover / disabled / 空态都摆出来。" layout="column">
<Xxx>默认</Xxx>
<Xxx disabled>禁用</Xxx>
</Demo>
<PropsTable
rows={[
{ name: 'variant', type: "'primary' | 'secondary'", defaultValue: "'primary'", desc: '语义变体' },
{ name: '…rest', type: 'HTMLAttributes', desc: '透传原生属性' },
]}
/>
</Section>
)
}
2.4 契约(贴进 COMPONENTS.md §3)
见 COMPONENTS.md §8 的模板。
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:
import type { HTMLAttributes } from 'react'
import styles from './Spinner.module.css'
/**
* 加载指示:一个旋转的圆环。
*
* 按钮内部的 loading 由 Button 自己处理;
* 这个组件用于「整块区域正在加载」——列表首屏、面板刷新。
* 一行文字的占位请用 Skeleton,不要用 Spinner。
*/
export type SpinnerProps = {
size?: 'sm' | 'md'
/** 无障碍名称,屏幕阅读器会读出来 */
label?: string
} & HTMLAttributes<HTMLSpanElement>
export function Spinner({ size = 'md', label = '加载中', className, ...rest }: SpinnerProps) {
const classes = [styles.root, styles[size], className].filter(Boolean).join(' ')
return <span className={classes} role="status" aria-label={label} {...rest} />
}
3.3 样式(第 5 步)
src/components/Spinner.module.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:
export { Spinner } from './components/Spinner'
export type { SpinnerProps } from './components/Spinner'
3.5 展示页(第 7 步)
site/sections/Spinners.tsx:
import { Spinner } from '../../src'
import { Demo } from '../components/Demo'
import { PropsTable } from '../components/PropsTable'
import { Section } from '../components/Section'
export function Spinners() {
return (
<Section
id="spinner"
title="加载中"
api="Spinner"
desc="整块区域的加载指示;文字占位用 Skeleton,按钮内的加载态交给 Button 的 loading。"
>
<Demo title="尺寸" code={`<Spinner size="sm" />\n<Spinner size="md" />`}>
<Spinner size="sm" />
<Spinner size="md" />
</Demo>
<Demo title="带文字的加载区" layout="column" code={`<Spinner label="正在读取内容库" />`}>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--su-space-2)' }}>
<Spinner />
<span style={{ color: 'var(--su-text-muted)' }}>正在读取内容库…</span>
</span>
</Demo>
<PropsTable
rows={[
{ name: 'size', type: "'sm' | 'md'", defaultValue: "'md'", desc: '14px / 20px' },
{ name: 'label', type: 'string', defaultValue: "'加载中'", desc: '无障碍名称' },
]}
/>
</Section>
)
}
site/App.tsx:在「交互」组里加 { id: 'spinner', label: '加载中 Spinner' },并在 <Feedback /> 后挂 <Spinners />。
3.6 契约(第 9 步)
### 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 步)
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 步)
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. 评审时的三句话
- 够通用吗?(不是业务组件、不是一次性布局)
- 只用了令牌吗?(颜色/字号/间距没有字面量)
- 文档齐了吗?(展示页一节 + 契约 + 清单 + 导航)
三条都过,就可以合并。