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

11 KiB
Raw Permalink Blame History

新增一个组件 · 操作手册

配套:DESIGN.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

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. 评审时的三句话

  1. 够通用吗?(不是业务组件、不是一次性布局)
  2. 只用了令牌吗?(颜色/字号/间距没有字面量)
  3. 文档齐了吗?(展示页一节 + 契约 + 清单 + 导航)

三条都过,就可以合并。