前端组件设计

让 Agent 在新增或拆分组件时对齐项目的框架习惯、命名空间与 Storybook/文档模式,并兼顾可测试与 a11y。

SKILL 中应写明 Props/事件/插槽(或 children 组合)的边界:哪些是底层原语、哪些是业务封装,以及是否使用 compound components 模式。

要求列出键盘操作、焦点管理、aria-* 与角色,对表单类组件补充受控/非受控约定与错误态展示,避免仅实现视觉稿。

与设计系统技能配合:token、间距、断点引用方式一致;若项目有 eslint-plugin-jsx-a11y 或自定义规则,在步骤里点名需通过的检查项。

组件 API 设计原则与代码示例

// 组件 API 设计原则:类型/默认值/callback 命名约定
import React from 'react';

type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';

interface ButtonProps {
  // 语义化 variant,不用 color 或 type 表达视觉意图
  variant?: ButtonVariant;          // 默认 'primary'
  size?: ButtonSize;                // 默认 'md'
  disabled?: boolean;               // 标准 HTML 语义
  loading?: boolean;                // 加载态:显示 spinner,同时设 disabled
  // callback 统一用 on 前缀,类型明确
  onClick?: (event: React.MouseEvent<HTMLButtonElement>) => void;
  // 内容:children 优于 label prop
  children: React.ReactNode;
  // 测试友好:data-testid 作为顶层 prop
  'data-testid'?: string;
}

const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
  ({ variant = 'primary', size = 'md', disabled, loading, onClick, children, ...rest }, ref) => (
    <button
      ref={ref}
      className={`btn btn-${variant} btn-${size}`}
      disabled={disabled || loading}
      aria-busy={loading}
      onClick={onClick}
      {...rest}
    >
      {loading && <span aria-hidden="true">⟳</span>}
      {children}
    </button>
  )
);
Button.displayName = 'Button';

Compound Component 模式——Form + Form.Item 组合:

// Compound Component:Form + Form.Item 通过 Context 共享隐式状态
const FormContext = React.createContext<FormContextValue | null>(null);

function Form({ onSubmit, children }: FormProps) {
  const [errors, setErrors] = React.useState<Record<string, string>>({});
  return (
    <FormContext.Provider value={{ errors, setErrors }}>
      <form onSubmit={onSubmit}>{children}</form>
    </FormContext.Provider>
  );
}

// Form.Item:自动从 Context 读取错误状态,关联 label 与 input
Form.Item = function FormItem({ name, label, children }: FormItemProps) {
  const { errors } = React.useContext(FormContext)!;
  const inputId = `form-${name}`;
  const errorId = `${inputId}-error`;
  return (
    <div className="form-item">
      <label htmlFor={inputId}>{label}</label>
      {React.cloneElement(children as React.ReactElement, {
        id: inputId,
        'aria-describedby': errors[name] ? errorId : undefined,
        'aria-invalid': !!errors[name],
      })}
      {errors[name] && <span id={errorId} role="alert">{errors[name]}</span>}
    </div>
  );
};

// 使用:对外 API 清晰,不暴露内部 Context
<Form onSubmit={handleSubmit}>
  <Form.Item name="email" label="邮箱">
    <input type="email" name="email" />
  </Form.Item>
</Form>
  • 导出与文件结构:index 出口、共置测试与 stories 的路径约定。
  • 破坏性变更:Props 重命名或默认值变更时要求迁移说明与 codemod 提示。
  • 与父级契约:受控值、回调命名(onXxx)与「禁止透传」的 props 列表。

无障碍与 Props 契约

在 SKILL 中把「可见文案」与「仅读屏可见」分开:优先用真实文本节点;必要时为图标-only 控件暴露 aria-label 或与 aria-labelledby 指向的稳定 id。

常见对外 Props / 属性(按项目框架映射为 props 或顶层属性)建议显式列出,避免散落在实现里:

  • 命名与描述:aria-labelaria-labelledbyaria-describedby(错误提示、帮助文案)。
  • 状态:disabledreadOnlyaria-invalidaria-requiredaria-busyaria-expandedaria-selected
  • 角色与模式:必要时 role(仅当原生语义不足)、复合部件时的 aria-controlsaria-activedescendant
  • 焦点:是否需 autoFocus、打开层时焦点陷阱、关闭时焦点归还(在步骤中写清,而不是隐含在代码里)。
给 Agent:表单关联优先用 htmlFor / for 绑定真实 <label>;不要用占位符代替 label。自定义控件时补充键盘表(Tab、Arrow、Enter、Escape)。

从原语到封装的流程

  [ 设计稿 / 用例 ]
        │
        ▼
  ┌─────────────┐     单一职责:一个组件一种「用户任务」
  │  职责切分    │──── 原语(Button) vs 复合(DateRangePicker)
  └─────────────┘
        │
        ▼
  ┌─────────────┐     Props / 事件 / 插槽边界写入 SKILL
  │  对外 API    │──── 受控与非受控、默认值、forwardRef(若适用)
  └─────────────┘
        │
        ▼
  ┌─────────────┐     角色、标签、键盘、焦点、错误态文案
  │  a11y 契约   │──── 对齐 eslint-plugin-jsx-a11y / 团队规则
  └─────────────┘
        │
        ▼
  ┌─────────────┐     Story / 单测 / 视觉回归(按项目)
  │ 文档与测试   │
  └─────────────┘

Headless vs Styled、可组合性与测试友好

// Headless 组件:只提供逻辑,样式由消费者控制(适合设计系统底层)
function useToggle(initialOpen = false) {
  const [open, setOpen] = React.useState(initialOpen);
  return {
    open,
    toggle: () => setOpen(o => !o),
    // getToggleProps:消费者传入后可扩展,而非内置样式
    getToggleProps: () => ({ onClick: () => setOpen(o => !o), 'aria-expanded': open }),
    getContentProps: () => ({ hidden: !open }),
  };
}

// 消费者自由控制样式:Headless vs Styled 的适用场景
function Accordion({ title, children }) {
  const { open, getToggleProps, getContentProps } = useToggle();
  return (
    <div>
      <button {...getToggleProps()} className="accordion-trigger">{title}</button>
      <div {...getContentProps()} className="accordion-content">{children}</div>
    </div>
  );
}

// 测试友好:data-testid 规范 + RTL 最佳实践
function UserCard({ user }: { user: User }) {
  return (
    <article data-testid="user-card" aria-label={`用户 ${user.name}`}>
      <h2 data-testid="user-name">{user.name}</h2>
      <button data-testid="edit-user-btn" onClick={() => onEdit(user.id)}>编辑</button>
    </article>
  );
}

// 对应测试:优先用 role/label 查询,data-testid 作为最后手段
// test('shows user name', () => {
//   render(<UserCard user={{ id: '1', name: '张三' }} />);
//   expect(screen.getByRole('article', { name: '用户 张三' })).toBeInTheDocument();
//   expect(screen.getByTestId('user-name')).toHaveTextContent('张三');
// });
  • 设计系统:token、间距、断点与变体(size / tone)的引用方式与禁止硬编码色值。
  • 测试:交互与 a11y 断言(如角色、名称):与 Vue Test Utils / RTL 等项目工具对齐。
  • 性能:大列表虚拟化、memo / useCallback 的启用准则,避免无差别优化。

页内工具:a11y / Props 草稿

以下脚本仅在本页运行:按组件名与交互模式生成一段可粘贴进 SKILL 或 PR 描述的 Props / a11y 要点清单。

参数

用于生成清单标题与示例前缀,可留空则使用通用占位名。

交互模式(可多选)

生成结果需按项目框架(Vue/React/Svelte 等)改写为真实类型与文档;键盘表请补全具体键位与焦点顺序。

SKILL 大纲

---
name: frontend-component
description: 按项目约定设计可访问、可测试、可组合的 UI 组件
---
# API 设计
1. Props 类型完整定义(TypeScript interface),可选项提供默认值
2. 回调命名 on 前缀:onClick/onChange/onSubmit,类型标注参数与返回值
3. forwardRef 用于需要 ref 的底层组件(input/button/等)
4. children 优于 label prop,slots/render props 优于长条件渲染

# 组合模式选择
5. Compound Component:父子通过 Context 共享状态,对外 API 稳定(Form.Item)
6. Headless Hook:只暴露状态与 getXxxProps,样式由消费者控制
7. Render Props:「将同一逻辑用于不同 UI」时,类型标注参数避免 any
8. 扩展点通过组合暴露,而非在父组件内用 variant 字符串条件渲染子类型

# 无障碍(a11y)
9. 表单控件:label/htmlFor 关联;aria-describedby 指向错误与帮助文案
10. 浮层:打开时焦点移入,关闭时焦点归还触发器;Escape 关闭
11. 列表/菜单:方向键导航,aria-activedescendant 指向当前项
12. 图标按钮:aria-label 或 aria-labelledby 指向可见文本

# 测试友好
13. data-testid 命名规范:组件名-语义(user-card, edit-btn)
14. 优先用 role/name 查询(getByRole),data-testid 作为最后手段
15. 每个 Story 覆盖:默认态、disabled、loading、error 各一个 variant

返回技能库 更多技能入口