Files
stash-ui/docs/ADDING-A-COMPONENT.md
T

366 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 新增一个组件 · 操作手册
> 配套:[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. **文档齐了吗?**(展示页一节 + 契约 + 清单 + 导航)
三条都过,就可以合并。