366 lines
11 KiB
Markdown
366 lines
11 KiB
Markdown
# 新增一个组件 · 操作手册
|
||
|
||
> 配套:[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<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
|
||
|
||
```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 (
|
||
<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](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<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`:
|
||
|
||
```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 (
|
||
<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 步)
|
||
|
||
```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. **文档齐了吗?**(展示页一节 + 契约 + 清单 + 导航)
|
||
|
||
三条都过,就可以合并。
|