mcpField
B
build-primitive

build-primitive

github.com/prototyperai设计与 UI763 次安装23 周前更新
代码仓库

关于 build-primitive

为 Base UI 缺少合适组件时构建自定义 React 基础组件提供指南,涵盖无障碍、交互、状态、样式和表单模式。

主要功能

  1. 01ARIA 与语义 HTML 指南
  2. 02键盘导航与焦点模式
  3. 03受控与非受控状态
  4. 04表单提交集成
  5. 05展示型与复合型模板

适用场景

  1. 01构建自定义日历或树视图
  2. 02为复合组件添加键盘支持
  3. 03让自定义控件参与表单提交

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/prototyperai/prototyper-ui --skill build-primitive

安装后在 Claude Code 中输入/build-primitive确认已加载。

详细介绍

原型 UI — 构建基础组件

当不存在 Base UI 基础组件时,如何从零开始构建一个 10/10 的组件。先定义 ARIA 契约,再实现键盘导航,然后管理状态、添加样式钩子,最后集成表单。

何时适用? 当你需要 Base UI 未提供的组件时,例如 Breadcrumb、Avatar、Badge、Card、Skeleton、Rating、Pagination、Stepper、Tag Input、File Upload、Calendar、Data Table、Carousel、Command Palette、Resizable Panels、Tree View 等。

理念: 高质量的基础组件是隐形的基础设施。它会处理所有边缘情况,让使用者无需操心。每个交互元素都必须支持键盘操作,每种状态都必须向辅助技术播报,每个动画都必须尊重 prefers-reduced-motion,每个表单控件都必须参与原生表单提交。


1. 决策关卡

从零开始构建前,请确认没有现成的方案能处理难点。

Does a Base UI primitive exist for this component?
│
├── Yes → Use /create-component skill (wrap the primitive)
│
├── Partially (e.g., Collapsible exists but not Accordion)
│   └── Compose from existing primitives + custom logic
│       (Accordion = Collapsible + custom keyboard nav + ARIA)
│
└── No primitive exists
    │
    ├── Is it purely presentational? (Avatar, Badge, Card, Skeleton)
    │   └── Section 2 — Presentational Template (simple, no hooks)
    │
    ├── Is it a simple interactive? (Breadcrumb, Pagination, Rating)
    │   └── Section 3 — Interactive Template (ARIA + keyboard)
    │
    ├── Is it a form control? (Tag Input, File Upload, Color Picker)
    │   └── Section 4 — Form Control Template (ARIA + keyboard + form)
    │
    └── Is it a complex composite? (Calendar, Data Table, Tree View)
        └── Section 5 — Composite Template (all patterns combined)
第三方库能处理难点吗?

从零开始构建复杂交互前,请检查无头库是否提供了状态机:

组件可考虑使用原因
Calendar / Date Pickerreact-aria(日期基础组件)日期计算、本地化、时区
Data Table@tanstack/react-table排序、筛选、分页、虚拟化
Command Palettecmdk模糊搜索、键盘导航、评分
Carouselembla-carousel-react吸附点、拖动效果、循环
Resizable Panelsreact-resizable-panels拖动、约束、持久化
Virtual Lists@tanstack/react-virtual窗口化、动态高度
DnD / Sortable@dnd-kit/core拖动效果、碰撞检测、无障碍支持

如果存在无头库,请使用 Prototyper UI 的样式模式(data-slot、cn()、令牌、动画)对其进行封装,而不是重新实现状态机。


2. 展示型模板

适用于没有交互行为的组件——只包含语义化 HTML、样式和插槽。

适用于:Avatar、Badge、Card、Skeleton、Separator、AspectRatio

"use client";

import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";

// --- Variants ---

const avatarVariants = cva(
  [
    "relative inline-flex items-center justify-center overflow-hidden",
    "rounded-full bg-muted text-muted-foreground",
    "select-none",
  ],
  {
    variants: {
      size: {
        sm: "size-8 text-xs",
        default: "size-10 text-sm",
        lg: "size-12 text-base",
      },
    },
    defaultVariants: {
      size: "default",
    },
  },
);

// --- Root ---

function Avatar({
  className,
  size,
  ...props
}: React.ComponentProps<"span"> & VariantProps<typeof avatarVariants>) {
  return (
    <span
      data-slot="avatar"
      className={cn(avatarVariants({ size }), className)}
      {...props}
    />
  );
}

// --- Image (with fallback handling) ---

function AvatarImage({
  className,
  onError,
  ...props
}: React.ComponentProps<"img">) {
  return (
    <img
      data-slot="avatar-image"
      className={cn("aspect-square size-full object-cover", className)}
      onError={onError}
      {...props}
    />
  );
}

// --- Fallback ---

function AvatarFallback({ className, ...props }: React.ComponentProps<"span">) {
  return (
    <span
      data-slot="avatar-fallback"
      className={cn(
        "flex size-full items-center justify-center bg-muted font-medium",
        className,
      )}
      {...props}
    />
  );
}

export { Avatar, AvatarImage, AvatarFallback, avatarVariants };
展示型组件规则
  • 使用语义化 HTML 元素(<nav>、<ol>、<figure>、<time>、<hr>)——存在语义化元素时,绝不使用 <div>
  • HTML 语义足够时,无需 ARIA 角色
  • 无需处理键盘操作(除非包含可交互的子元素)
  • Props 类型:React.ComponentProps<"element">——而非 Base UI 类型
  • 仍需包含:data-slot、cn() 合并;存在变体时使用 CVA
  • 仍需包含:"use client"、具名导出、不使用 forwardRef

3. 交互型模板

适用于带有键盘交互和 ARIA 状态、但不参与表单提交的组件。

适用于:Breadcrumb、Pagination、Rating、Stepper、Disclosure、Tree View

"use client";

import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";

import { cn } from "@/lib/utils";

// --- Controlled/Uncontrolled State Hook ---

function useControllableState<T>(
  controlledValue: T | undefined,
  defaultValue: T,
  onChange?: (value: T) => void,
): [T, (value: T | ((prev: T) => T)) => void] {
  const [internal, setInternal] = React.useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const value = isControlled ? controlledValue : internal;

  const setValue = React.useCallback(
    (next: T | ((prev: T) => T)) => {
      const nextValue =
        typeof next === "function"
          ? (next as (prev: T) => T)(isControlled ? controlledValue! : internal)
          : next;
      if (!isControlled) setInternal(nextValue);
      onChange?.(nextValue);
    },
    [isControlled, controlledValue, internal, onChange],
  );

  return [value, setValue];
}

// --- Root ---

function Rating({
  className,
  value: controlledValue,
  defaultValue = 0,
  max = 5,
  onValueChange,
  disabled = false,
  readOnly = false,
  ...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  value?: number;
  defaultValue?: number;
  max?: number;
  onValueChange?: (value: number) => void;
  disabled?: boolean;
  readOnly?: boolean;
}) {
  const [value, setValue] = useControllableState(
    controlledValue,
    defaultValue,
    onValueChange,
  );
  const [hovered, setHovered] = React.useState<number | null>(null);

  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent) => {
      if (disabled || readOnly) return;

      let next = value;
      switch (event.key) {
        case "ArrowRight":
        case "ArrowUp":
          next = Math.min(value + 1, max);
          break;
        case "ArrowLeft":
        case "ArrowDown":
          next = Math.max(value - 1, 0);
          break;
        case "Home":
          next = 0;
          break;
        case "End":
          next = max;
          break;
        default:
          return;
      }

      event.preventDefault();
      setValue(next);
    },
    [disabled, readOnly, value, max, setValue],
  );

  return (
    <div
      data-slot="rating"
      role="slider"
      aria-valuenow={value}
      aria-valuemin={0}
      aria-valuemax={max}
      aria-disabled={disabled || undefined}
      aria-readonly={readOnly || undefined}
      tabIndex={disabled ? -1 : 0}
      className={cn(
        "inline-flex gap-0.5",
        "focus-visible:focus-ring rounded-sm",
        disabled && "status-disabled",
        className,
      )}
      onKeyDown={handleKeyDown}
      onMouseLeave={() => setHovered(null)}
      {...props}
    >
      {Array.from({ length: max }, (_, i) => (
        <RatingItem
          key={i}
          index={i + 1}
          filled={(hovered ?? value) >= i + 1}
          onHover={() => !disabled && !readOnly && setHovered(i + 1)}
          onSelect={() => !disabled && !readOnly && setValue(i + 1)}
        />
      ))}
    </div>
  );
}

// --- Item ---

function RatingItem({
  filled,
  onHover,
  onSelect,
  index,
}: {
  filled: boolean;
  index: number;
  onHover: () => void;
  onSelect: () => void;
}) {
  return (
    <span
      data-slot="rating-item"
      data-filled={filled || undefined}
      aria-hidden="true"
      className={cn(
        "cursor-pointer text-muted transition-colors duration-150 ease-smooth",
        "motion-reduce:transition-none",
        "data-filled:text-primary",
        "hover-only:hover:scale-110",
      )}
      onMouseEnter={onHover}
      onClick={onSelect}
    >
      ★
    </span>
  );
}

export { Rating, RatingItem };
交互模式说明

此模板展示了构建交互型组件所需的所有核心模式:

1. 受控/非受控双模式(useControllableState 钩子) 2. ARIA 角色和状态(role="slider"、aria-valuenow、aria-valuemin、aria-valuemax) 3. 键盘导航(使用方向键、Home/End 的 onKeyDown) 4. 焦点管理(tabIndex、focus-visible:focus-ring) 5. 用于样式设置的数据属性(项目上的 data-filled) 6. 禁用/只读状态(aria-disabled、条件式键盘处理)


4. 表单控件模板

适用于参与表单提交的组件——需要隐藏输入和与 Field 兼容。

适用于:Tag Input、File Upload、Color Picker、Custom Select

"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

// --- useControllableState (same as Section 3, extract to shared hook) ---

function useControllableState<T>(
  controlledValue: T | undefined,
  defaultValue: T,
  onChange?: (value: T) => void,
): [T, (value: T | ((prev: T) => T)) => void] {
  const [internal, setInternal] = React.useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const value = isControlled ? controlledValue : internal;

  const setValue = React.useCallback(
    (next: T | ((prev: T) => T)) => {
      const nextValue =
        typeof next === "function"
          ? (next as (prev: T) => T)(isControlled ? controlledValue! : internal)
          : next;
      if (!isControlled) setInternal(nextValue);
      onChange?.(nextValue);
    },
    [isControlled, controlledValue, internal, onChange],
  );

  return [value, setValue];
}

// --- TagInput ---

function TagInput({
  className,
  name,
  value: controlledValue,
  defaultValue = [],
  onValueChange,
  disabled = false,
  required = false,
  maxTags,
  ...props
}: Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> & {
  name?: string;
  value?: string[];
  defaultValue?: string[];
  onValueChange?: (value: string[]) => void;
  disabled?: boolean;
  required?: boolean;
  maxTags?: number;
}) {
  const [tags, setTags] = useControllableState(
    controlledValue,
    defaultValue,
    onValueChange,
  );
  const [inputValue, setInputValue] = React.useState("");
  const inputRef = React.useRef<HTMLInputElement>(null);

  const addTag = React.useCallback(
    (tag: string) => {
      const trimmed = tag.trim();
      if (!trimmed || tags.includes(trimmed)) return;
      if (maxTags && tags.length >= maxTags) return;
      setTags([...tags, trimmed]);
    },
    [tags, maxTags, setTags],
  );

  const removeTag = React.useCallback(
    (index: number) => {
      setTags(tags.filter((_, i) => i !== index));
    },
    [tags, setTags],
  );

  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent<HTMLInputElement>) => {
      if (disabled) return;

      switch (event.key) {
        case "Enter":
          event.preventDefault();
          addTag(inputValue);
          setInputValue("");
          break;
        case "Backspace":
          if (inputValue === "" && tags.length > 0) {
            removeTag(tags.length - 1);
          }
          break;
      }
    },
    [disabled, inputValue, tags, addTag, removeTag],
  );

  return (
    <div
      data-slot="tag-input"
      data-disabled={disabled || undefined}
      className={cn(
        "flex min-h-9 flex-wrap items-center gap-1.5 rounded-lg border border-field-border",
        "bg-field-background px-3 py-1.5 shadow-field",
        "transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-smooth",
        "motion-reduce:transition-none",
        "focus-within:focus-field-ring",
        "data-disabled:status-disabled",
        className,
      )}
      onClick={() => inputRef.current?.focus()}
      {...props}
    >
      {tags.map((tag, index) => (
        <TagInputTag
          key={tag}
          onRemove={() => removeTag(index)}
          disabled={disabled}
        >
          {tag}
        </TagInputTag>
      ))}

      <input
        ref={inputRef}
        data-slot="tag-input-field"
        type="text"
        value={inputValue}
        onChange={(e) => setInputValue(e.target.value)}
        onKeyDown={handleKeyDown}
        disabled={disabled}
        className={cn(
          "min-w-[60px] flex-1 bg-transparent text-sm outline-none",
          "placeholder:text-muted-foreground",
        )}
        aria-label="Add tag"
      />

      {/* Hidden inputs for form submission */}
      {name &&
        tags.map((tag) => (
          <input key={tag} type="hidden" name={name} value={tag} />
        ))}

      {/* Validation: required + empty = invalid */}
      {name && required && tags.length === 0 && (
        <input
          type="text"
          name={`${name}_validation`}
          required
          value=""
          onChange={() => {}}
          className="sr-only"
          tabIndex={-1}
          aria-hidden="true"
        />
      )}
    </div>
  );
}

// --- Tag ---

function TagInputTag({
  children,
  onRemove,
  disabled,
  className,
  ...props
}: React.ComponentProps<"span"> & {
  onRemove: () => void;
  disabled?: boolean;
}) {
  return (
    <span
      data-slot="tag-input-tag"
      className={cn(
        "inline-flex items-center gap-1 rounded-md bg-primary-soft px-2 py-0.5 text-xs font-medium",
        "transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-smooth",
        "motion-reduce:transition-none",
        className,
      )}
      {...props}
    >
      {children}
      {!disabled && (
        <button
          data-slot="tag-input-remove"
          type="button"
          onClick={onRemove}
          aria-label={`Remove ${children}`}
          className={cn(
            "inline-flex size-3.5 items-center justify-center rounded-sm",
            "hover-only:hover:bg-primary-soft-hover",
            "focus-visible:focus-ring",
            "no-highlight",
          )}
        >
          ×
        </button>
      )}
    </span>
  );
}

export { TagInput, TagInputTag };
表单集成模式

用于表单提交的隐藏输入:

// Single value — one hidden input
<input type="hidden" name={name} value={value} />;

// Multiple values — one hidden input per value (same name)
{
  values.map((v) => <input key={v} type="hidden" name={name} value={v} />);
}

使用隐藏输入进行验证:

// Required field with custom control
{
  required && isEmpty && (
    <input
      type="text"
      required
      value=""
      onChange={() => {}}
      className="sr-only"
      tabIndex={-1}
      aria-hidden="true"
    />
  );
}

与字段包装器兼容: 组件必须支持由 <Field> 包装:

<Field>
  <FieldLabel>Tags</FieldLabel>
  <TagInput name="tags" required />
  <FieldError>Please add at least one tag.</FieldError>
</Field>

要实现这一点:

  • 可见输入必须通过 id 关联到 Field(Field 会自动生成此属性)
  • 错误状态应响应父级 Field 的 data-invalid
  • 如果容器带有边框,请添加 data-invalid:invalid-field-ring

5. 复合组件模板

适用于具有多个交互区域、循环焦点和 ARIA 复合模式的复杂组件。

适用组件:日历、数据表、树视图、命令面板

此模板展示了循环 tabindex模式,这是所有支持方向键导航的复合小部件的基础。

"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

// --- Roving Focus Hook ---

function useRovingFocus(
  containerRef: React.RefObject<HTMLElement | null>,
  options: {
    orientation?: "horizontal" | "vertical" | "both";
    loop?: boolean;
    selector?: string;
  } = {},
) {
  const {
    orientation = "both",
    loop = true,
    selector = "[data-roving-item]",
  } = options;

  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent) => {
      const container = containerRef.current;
      if (!container) return;

      const items = Array.from(
        container.querySelectorAll<HTMLElement>(selector),
      ).filter((el) => !el.hasAttribute("data-disabled"));

      const currentIndex = items.indexOf(document.activeElement as HTMLElement);
      if (currentIndex === -1) return;

      let nextIndex: number | null = null;

      const isHorizontal =
        orientation === "horizontal" || orientation === "both";
      const isVertical = orientation === "vertical" || orientation === "both";

      switch (event.key) {
        case "ArrowRight":
          if (isHorizontal) nextIndex = currentIndex + 1;
          break;
        case "ArrowLeft":
          if (isHorizontal) nextIndex = currentIndex - 1;
          break;
        case "ArrowDown":
          if (isVertical) nextIndex = currentIndex + 1;
          break;
        case "ArrowUp":
          if (isVertical) nextIndex = currentIndex - 1;
          break;
        case "Home":
          nextIndex = 0;
          break;
        case "End":
          nextIndex = items.length - 1;
          break;
        default:
          return;
      }

      if (nextIndex === null) return;
      event.preventDefault();

      // Loop or clamp
      if (loop) {
        nextIndex = ((nextIndex % items.length) + items.length) % items.length;
      } else {
        nextIndex = Math.max(0, Math.min(nextIndex, items.length - 1));
      }

      // Roving tabindex: deactivate current, activate next
      items[currentIndex].setAttribute("tabindex", "-1");
      items[nextIndex].setAttribute("tabindex", "0");
      items[nextIndex].focus();
    },
    [containerRef, orientation, loop, selector],
  );

  return { handleKeyDown };
}

// --- Example: Pagination ---

function Pagination({
  className,
  page: controlledPage,
  defaultPage = 1,
  total,
  onPageChange,
  ...props
}: Omit<React.ComponentProps<"nav">, "onChange"> & {
  page?: number;
  defaultPage?: number;
  total: number;
  onPageChange?: (page: number) => void;
}) {
  const [page, setPage] = useControllableState(
    controlledPage,
    defaultPage,
    onPageChange,
  );
  const containerRef = React.useRef<HTMLElement>(null);
  const { handleKeyDown } = useRovingFocus(containerRef, {
    orientation: "horizontal",
  });

  return (
    <nav
      ref={containerRef}
      data-slot="pagination"
      role="navigation"
      aria-label="Pagination"
      className={cn("flex items-center gap-1", className)}
      onKeyDown={handleKeyDown}
      {...props}
    >
      <PaginationItem
        disabled={page <= 1}
        onClick={() => setPage(page - 1)}
        aria-label="Previous page"
        isFirst
      >
        ‹
      </PaginationItem>

      {Array.from({ length: total }, (_, i) => (
        <PaginationItem
          key={i + 1}
          active={page === i + 1}
          onClick={() => setPage(i + 1)}
          aria-label={`Page ${i + 1}`}
          aria-current={page === i + 1 ? "page" : undefined}
        >
          {i + 1}
        </PaginationItem>
      ))}

      <PaginationItem
        disabled={page >= total}
        onClick={() => setPage(page + 1)}
        aria-label="Next page"
        isLast
      >
        ›
      </PaginationItem>
    </nav>
  );
}

function PaginationItem({
  active,
  disabled,
  isFirst,
  isLast,
  className,
  ...props
}: React.ComponentProps<"button"> & {
  active?: boolean;
  isFirst?: boolean;
  isLast?: boolean;
}) {
  return (
    <button
      data-slot="pagination-item"
      data-roving-item
      data-active={active || undefined}
      data-disabled={disabled || undefined}
      type="button"
      disabled={disabled}
      tabIndex={active || (isFirst && !disabled) ? 0 : -1}
      className={cn(
        "inline-flex size-9 items-center justify-center rounded-md text-sm",
        "transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-smooth",
        "motion-reduce:transition-none",
        "hover-only:hover:bg-accent hover-only:hover:text-accent-foreground",
        "focus-visible:focus-ring",
        "disabled:status-disabled",
        "no-highlight",
        "data-active:bg-primary data-active:text-primary-foreground",
        className,
      )}
      {...props}
    />
  );
}

export { Pagination, PaginationItem };

6. ARIA 角色选择

不同组件应使用哪种角色?
What does the component represent?
│
├── A clickable action?
│   ├── Navigates to URL → role not needed (<a> has implicit link role)
│   ├── Triggers action → role not needed (<button> has implicit button role)
│   └── Toggles state → role="switch" or aria-pressed="true/false"
│
├── A value in a range?
│   ├── User can change it → role="slider" (+ aria-valuenow/min/max)
│   ├── Display only, known range → role="meter" (+ aria-valuenow/min/max)
│   └── Display only, indeterminate → role="progressbar" (+ aria-valuenow or none)
│
├── A selection from options?
│   ├── Single select, dropdown → role="listbox" (items: role="option")
│   ├── Single select, inline → role="radiogroup" (items: role="radio")
│   ├── Multi-select → role="listbox" + aria-multiselectable="true"
│   └── Searchable select → role="combobox" (input) + role="listbox" (popup)
│
├── A group of related controls?
│   ├── Actions/buttons → role="toolbar"
│   ├── Navigation links → role not needed (<nav> has implicit navigation role)
│   ├── Tabs → role="tablist" (items: role="tab", panels: role="tabpanel")
│   └── Generic group → role="group" (+ aria-label)
│
├── A hierarchical structure?
│   ├── Tree of items → role="tree" (items: role="treeitem")
│   ├── Tabular data → role not needed (<table> has implicit table role)
│   └── Interactive grid → role="grid" (cells: role="gridcell")
│
├── An overlay?
│   ├── Blocks interaction → role="dialog" + aria-modal="true"
│   ├── Important message → role="alertdialog" + aria-modal="true"
│   └── Tooltip-like → role="tooltip"
│
└── A notification?
    ├── Urgent → role="alert" (implies aria-live="assertive")
    ├── Status update → role="status" (implies aria-live="polite")
    └── Running log → role="log" (implies aria-live="polite")
必要的 ARIA 状态属性
属性值用于
aria-expanded"true" / "false"展开触发器、手风琴标题、树项
aria-selected"true" / "false"标签页、列表框选项、树项
aria-checked"true" / "false" / "mixed"复选框、单选按钮、开关
aria-pressed"true" / "false"切换按钮
aria-current"page" / "step" / "true"面包屑当前项、步进器当前步骤
aria-disabled"true"已禁用的交互元素
aria-hidden"true"装饰性元素、文本标签旁的图标
aria-valuenow数字滑块、仪表、进度条
aria-valuemin数字滑块、仪表、进度条
aria-valuemax数字滑块、仪表、进度条
aria-valuetext字符串仅用数字不足以表达时的滑块(例如,"50%")
aria-live"polite" / "assertive"动态更新内容的区域
aria-modal"true"会锁定焦点的对话框
命名规则
Does the element have visible text content?
│
├── Yes, and it's a button/link/heading → Name from content (automatic)
│
├── Yes, but text is ambiguous → aria-labelledby pointing to specific text
│   (e.g., "Read more..." → aria-labelledby="read-more heading-id")
│
├── No visible text (icon-only button) → aria-label="Close"
│
├── Form control → <label> element (explicit for= or implicit wrapping)
│
├── Group of controls → <fieldset> + <legend>
│   Or role="group" + aria-label="Group name"
│
├── Navigation region → <nav aria-label="Main navigation">
│
└── Decorative → aria-hidden="true" (remove from accessibility tree)

切勿在标签中包含角色名称。“关闭”,而不是“关闭按钮”。“导航”,而不是“导航 nav”。


7. 键盘导航模式

模式 A:单个可聚焦元素

适用于只有一个交互表面的组件(按钮、切换、评分、展开/折叠)。

Tab → Focus the element
Enter/Space → Activate
Arrow keys → Adjust value (if applicable)
function handleKeyDown(event: React.KeyboardEvent) {
  switch (event.key) {
    case "Enter":
    case " ":
      event.preventDefault();
      activate();
      break;
    case "ArrowRight":
    case "ArrowUp":
      event.preventDefault();
      increment();
      break;
    case "ArrowLeft":
    case "ArrowDown":
      event.preventDefault();
      decrement();
      break;
    case "Home":
      event.preventDefault();
      setToMinimum();
      break;
    case "End":
      event.preventDefault();
      setToMaximum();
      break;
  }
}
模式 B:循环 Tabindex(复合小部件)

适用于包含多个可聚焦项、且任意时刻只有一个项位于 Tab 键顺序中的组件(工具栏、标签页、列表框、单选按钮组、分页)。

Tab → Focus the active item (only one in tab order)
Arrow keys → Move focus between items
Home → Focus first item
End → Focus last item
Enter/Space → Select/activate focused item

实现方式:参见第 5 节中的 useRovingFocus hook。

规则:

  • 当前项使用 tabIndex={0},其他所有项使用 tabIndex={-1}
  • 方向键移动焦点并更新 tabIndex 值
  • 初次获得焦点时,聚焦到选中项(如果没有选中项,则聚焦到第一项)
  • 循环:到达末尾时绕回开头(可选,可配置)
模式 C:活动后代(组合框样式)

适用于焦点停留在输入框上,同时单独的列表显示视觉高亮项的组件(组合框、命令面板)。

Tab → Focus the input
Arrow keys → Move highlight in the list (focus stays on input)
Enter → Select highlighted item
Escape → Close popup / clear
<input
  role="combobox"
  aria-expanded={open}
  aria-activedescendant={highlightedId}
  aria-controls={listId}
  aria-autocomplete="list"
  onKeyDown={handleKeyDown}
/>

<ul id={listId} role="listbox">
  {items.map((item) => (
    <li
      key={item.id}
      id={item.id}
      role="option"
      aria-selected={item.id === highlightedId}
      data-highlighted={item.id === highlightedId || undefined}
    >
      {item.label}
    </li>
  ))}
</ul>

在以下情况下,请使用 aria-activedescendant,而不是循环 tabindex:

  • 焦点必须停留在输入框上(组合框、搜索框)
  • 列表经过虚拟化处理(并非所有项都在 DOM 中)
  • 需要类型前缀搜索
模式 D:网格导航

适用于二维导航(日历、数据表)。

ArrowRight → Next cell
ArrowLeft → Previous cell
ArrowDown → Same column, next row
ArrowUp → Same column, previous row
Home → First cell in row
End → Last cell in row
Ctrl+Home → First cell in grid
Ctrl+End → Last cell in grid
PageDown → Same cell, next page/month
PageUp → Same cell, previous page/month
function handleGridKeyDown(
  event: React.KeyboardEvent,
  row: number,
  col: number,
  rows: number,
  cols: number,
) {
  let nextRow = row;
  let nextCol = col;

  switch (event.key) {
    case "ArrowRight":
      nextCol = col + 1;
      break;
    case "ArrowLeft":
      nextCol = col - 1;
      break;
    case "ArrowDown":
      nextRow = row + 1;
      break;
    case "ArrowUp":
      nextRow = row - 1;
      break;
    case "Home":
      nextCol = event.ctrlKey ? 0 : 0;
      if (event.ctrlKey) nextRow = 0;
      break;
    case "End":
      nextCol = event.ctrlKey ? cols - 1 : cols - 1;
      if (event.ctrlKey) nextRow = rows - 1;
      break;
    default:
      return;
  }

  event.preventDefault();
  // Clamp or wrap
  nextRow = Math.max(0, Math.min(nextRow, rows - 1));
  nextCol = Math.max(0, Math.min(nextCol, cols - 1));
  focusCell(nextRow, nextCol);
}
类型预判搜索

对于列表框/树形/菜单组件,输入字符即可跳转到匹配项:

function useTypeahead(
  items: { label: string; element: HTMLElement }[],
  options: { timeout?: number } = {},
) {
  const { timeout = 500 } = options;
  const searchRef = React.useRef("");
  const timerRef = React.useRef<ReturnType<typeof setTimeout>>();

  const handleKeyDown = React.useCallback(
    (event: React.KeyboardEvent) => {
      // Ignore control keys
      if (
        event.key.length !== 1 ||
        event.ctrlKey ||
        event.metaKey ||
        event.altKey
      )
        return;

      clearTimeout(timerRef.current);
      searchRef.current += event.key.toLowerCase();

      const match = items.find((item) =>
        item.label.toLowerCase().startsWith(searchRef.current),
      );
      if (match) match.element.focus();

      timerRef.current = setTimeout(() => {
        searchRef.current = "";
      }, timeout);
    },
    [items, timeout],
  );

  return { handleKeyDown };
}

8. 焦点管理

焦点陷阱(模态框)

从头构建模态框时(不使用 Base UI Dialog),必须设置焦点陷阱:

function useFocusTrap(containerRef: React.RefObject<HTMLElement | null>) {
  React.useEffect(() => {
    const container = containerRef.current;
    if (!container) return;

    const focusableSelector = [
      "a[href]",
      "button:not(:disabled)",
      "input:not(:disabled)",
      "select:not(:disabled)",
      "textarea:not(:disabled)",
      '[tabindex]:not([tabindex="-1"])',
    ].join(", ");

    function handleKeyDown(event: KeyboardEvent) {
      if (event.key !== "Tab") return;

      const focusable = Array.from(
        container!.querySelectorAll<HTMLElement>(focusableSelector),
      );
      if (focusable.length === 0) return;

      const first = focusable[0];
      const last = focusable[focusable.length - 1];

      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        last.focus();
      } else if (!event.shiftKey && document.activeElement === last) {
        event.preventDefault();
        first.focus();
      }
    }

    container.addEventListener("keydown", handleKeyDown);
    return () => container.removeEventListener("keydown", handleKeyDown);
  }, [containerRef]);
}
焦点恢复

关闭覆盖层时,将焦点返回到打开该覆盖层的元素:

function useFocusRestoration(isOpen: boolean) {
  const triggerRef = React.useRef<HTMLElement | null>(null);

  React.useEffect(() => {
    if (isOpen) {
      // Capture the currently focused element when opening
      triggerRef.current = document.activeElement as HTMLElement;
    } else if (triggerRef.current) {
      // Restore focus when closing
      triggerRef.current.focus();
      triggerRef.current = null;
    }
  }, [isOpen]);
}
初始焦点

打开覆盖层时,将焦点移至其中第一个可聚焦元素:

React.useEffect(() => {
  if (!isOpen) return;
  // Delay to ensure DOM is painted
  requestAnimationFrame(() => {
    const container = containerRef.current;
    if (!container) return;
    const autofocus = container.querySelector<HTMLElement>(
      "[autofocus], [data-autofocus]",
    );
    const firstFocusable =
      container.querySelector<HTMLElement>(focusableSelector);
    (autofocus ?? firstFocusable ?? container).focus();
  });
}, [isOpen]);

9. 受控/非受控状态

useControllableState 钩子(见第 3 和第 4 节)是每个交互式基础组件的根基。它支持三种使用模式:

// Uncontrolled — component manages its own state
<Rating defaultValue={3} onValueChange={(v) => console.log(v)} />

// Controlled — parent manages state
<Rating value={rating} onValueChange={setRating} />

// Fully uncontrolled — no callbacks, just internal state
<Rating defaultValue={3} />
实现模式
function useControllableState<T>(
  controlledValue: T | undefined,
  defaultValue: T,
  onChange?: (value: T) => void,
): [T, (value: T | ((prev: T) => T)) => void] {
  const [internal, setInternal] = React.useState(defaultValue);
  const isControlled = controlledValue !== undefined;
  const value = isControlled ? controlledValue : internal;

  const setValue = React.useCallback(
    (next: T | ((prev: T) => T)) => {
      const nextValue =
        typeof next === "function"
          ? (next as (prev: T) => T)(isControlled ? controlledValue! : internal)
          : next;
      if (!isControlled) setInternal(nextValue);
      onChange?.(nextValue);
    },
    [isControlled, controlledValue, internal, onChange],
  );

  return [value, setValue];
}
属性约定

对于可控值,始终接受以下三个属性:

属性用途示例
value受控值(可选)value={3}
defaultValue非受控值的初始值defaultValue={0}
onValueChange值变化时的回调onValueChange={(v) => setV(v)}

将回调命名为 on{ValueName}Change,例如:onValueChange、onPageChange、onOpenChange、onCheckedChange。


10. 将数据属性用作样式钩子

创建自定义数据属性

通过 data-* 属性公开组件状态,让使用者能够通过 CSS 设置样式:

<div
  data-slot="rating"
  data-disabled={disabled || undefined}
  data-readonly={readOnly || undefined}
  data-value={value}
>
  <span
    data-slot="rating-item"
    data-filled={filled || undefined}
    data-highlighted={highlighted || undefined}
  />
</div>
规则
  1. 布尔属性:设为 true/存在或 undefined(绝不要设为 "false")

    data-active={isActive || undefined}      // Correct: present or absent
    data-active={isActive ? "true" : "false"} // Wrong: always present
    
  2. 枚举属性:使用字符串值

    data-state={state}           // "idle" | "loading" | "error"
    data-orientation={orientation} // "horizontal" | "vertical"
    data-side={side}             // "top" | "bottom" | "left" | "right"
    
  3. 数值属性:设为字符串(CSS 无法读取属性值,但 JS 可以读取)

    data-value={String(value)}
    
Tailwind 定向选择
// Boolean (presence)
"data-filled:text-primary";
"data-disabled:status-disabled";
"data-highlighted:bg-accent";

// Value-based
"data-[state=loading]:animate-pulse";
"data-[orientation=vertical]:flex-col";
"data-[side=top]:slide-in-from-bottom-2";

// Group data (from parent with group class)
"group-data-disabled:opacity-50";
"group-data-checked:bg-primary";

11. 用于动态值的 CSS 变量

将计算得出的值公开为 CSS 自定义属性,供使用者设置样式:

<div
  data-slot="progress"
  style={
    {
      "--progress-value": `${(value / max) * 100}%`,
      "--progress-max": String(max),
    } as React.CSSProperties
  }
>
  <div
    data-slot="progress-indicator"
    className="h-full bg-primary transition-[width] duration-300"
    style={{ width: "var(--progress-value)" }}
  />
</div>
常见的 CSS 变量
变量组件类型示例
--{component}-value进度条、仪表、滑块百分比或原始值
--{component}-max进度条、仪表最大值
--{component}-count标签输入框、分页项目数量
--available-height定位覆盖层溢出前的最大高度
--anchor-width定位覆盖层触发元素宽度
--transform-origin定位覆盖层动画原点
为 CSS 变量添加类型

React 的 CSSProperties 类型不包含自定义属性。请使用类型断言:

style={{ "--progress-value": `${pct}%` } as React.CSSProperties}

12. 点击外部区域与按 Escape 关闭

适用于未使用 Base UI Dialog/Popover 构建的覆盖层类组件。

function useClickOutside(
  ref: React.RefObject<HTMLElement | null>,
  handler: () => void,
  enabled: boolean = true,
) {
  React.useEffect(() => {
    if (!enabled) return;

    function handlePointerDown(event: PointerEvent) {
      if (ref.current && !ref.current.contains(event.target as Node)) {
        handler();
      }
    }

    // Use pointerdown, not click — click fires after mouseup,
    // which can be on a different element than mousedown
    document.addEventListener("pointerdown", handlePointerDown);
    return () => document.removeEventListener("pointerdown", handlePointerDown);
  }, [ref, handler, enabled]);
}

function useEscapeKey(handler: () => void, enabled: boolean = true) {
  React.useEffect(() => {
    if (!enabled) return;

    function handleKeyDown(event: KeyboardEvent) {
      if (event.key === "Escape") {
        event.stopPropagation(); // Prevent parent overlays from also closing
        handler();
      }
    }

    document.addEventListener("keydown", handleKeyDown);
    return () => document.removeEventListener("keydown", handleKeyDown);
  }, [handler, enabled]);
}
同时使用两者
function Disclosure({ ... }) {
  const [open, setOpen] = useControllableState(...)
  const contentRef = React.useRef<HTMLDivElement>(null)

  useClickOutside(contentRef, () => setOpen(false), open)
  useEscapeKey(() => setOpen(false), open)

  return (
    <>
      <button
        aria-expanded={open}
        aria-controls={contentId}
        onClick={() => setOpen(!open)}
      >
        {trigger}
      </button>
      {open && (
        <div ref={contentRef} id={contentId}>
          {children}
        </div>
      )}
    </>
  )
}

13. 动画生命周期(不使用 Base UI)

从头构建覆盖层时,需要自行管理挂载/卸载动画周期。Base UI 提供 data-starting-style/data-ending-style 和 getAnimations() 检测;从头构建时,请使用以下模式:

CSS 过渡方案(首选——可中断)
function useAnimatedPresence(isOpen: boolean) {
  const [mounted, setMounted] = React.useState(isOpen);
  const [visible, setVisible] = React.useState(isOpen);
  const ref = React.useRef<HTMLElement>(null);

  React.useEffect(() => {
    if (isOpen) {
      setMounted(true);
      // Force a layout read before setting visible (triggers transition)
      requestAnimationFrame(() => {
        requestAnimationFrame(() => setVisible(true));
      });
    } else {
      setVisible(false);
      // Wait for transition to finish before unmounting
      const el = ref.current;
      if (!el) return setMounted(false);

      function handleTransitionEnd() {
        setMounted(false);
        el?.removeEventListener("transitionend", handleTransitionEnd);
      }
      el.addEventListener("transitionend", handleTransitionEnd);
      return () =>
        el?.removeEventListener("transitionend", handleTransitionEnd);
    }
  }, [isOpen]);

  return { ref, mounted, visible };
}

用法:

function MyOverlay({ open }: { open: boolean }) {
  const { ref, mounted, visible } = useAnimatedPresence(open);

  if (!mounted) return null;

  return (
    <div
      ref={ref}
      data-slot="my-overlay"
      className={cn(
        "transition-[opacity,transform] duration-150 ease-out-fluid",
        "motion-reduce:transition-none",
        visible ? "opacity-100 scale-100" : "opacity-0 scale-95",
      )}
    />
  );
}
CSS 关键帧方案(用于不可中断的进入/退出动画)

如果你更喜欢关键帧动画(目前 Prototyper UI 的所有覆盖层都采用这种方式),请有条件地应用动画:

<div
  className={cn(
    open && "animate-in fade-in-0 zoom-in-95 duration-150",
    !open && "animate-out fade-out-0 zoom-out-95 duration-100",
    "motion-reduce:animate-none",
  )}
  onAnimationEnd={() => {
    if (!open) setMounted(false);
  }}
/>

14. 合并 Ref

当组件需要内部 ref,同时也必须接受用户的 ref 时:

function useMergedRef<T>(...refs: (React.Ref<T> | undefined)[]) {
  return React.useCallback(
    (node: T | null) => {
      refs.forEach((ref) => {
        if (!ref) return;
        if (typeof ref === "function") ref(node);
        else (ref as React.MutableRefObject<T | null>).current = node;
      });
    },
    // eslint-disable-next-line react-hooks/exhaustive-deps
    refs,
  );
}

// Usage
function MyComponent({
  ref: userRef,
  ...props
}: {
  ref?: React.Ref<HTMLDivElement>;
}) {
  const internalRef = React.useRef<HTMLDivElement>(null);
  const mergedRef = useMergedRef(internalRef, userRef);

  return <div ref={mergedRef} {...props} />;
}

15. 整合所有内容——检查清单

开始之前
  • 已确认此组件没有对应的 Base UI 基础组件
  • 已检查是否存在可处理状态机的无头库
  • 已确定 WAI-ARIA 模式(https://www.w3.org/WAI/ARIA/apg/patterns/)
  • 已根据第 6 节确定正确的 ARIA 角色
HTML 与 ARIA
  • 使用语义化 HTML 元素(<nav>、<button>、<input>、<ol>)——存在语义化元素时绝不使用 <div>
  • 在根元素或主要交互元素上设置正确的 ARIA 角色
  • 设置所有必需的 ARIA 状态(aria-expanded、aria-selected、aria-checked 等)
  • 交互元素具有可访问名称(可见文本、aria-label 或 <label>)
  • 装饰性元素设置 aria-hidden="true"
  • 为动态内容更新设置实时区域(aria-live)
  • 自定义控件使用 aria-disabled(而不只是 HTML disabled)
键盘
  • 所有功能均可通过键盘使用
  • 使用正确的键盘交互模式(单一焦点、roving tabindex 或 active descendant)
  • 使用方向键在复合控件中导航
  • 使用 Home/End 键跳转到第一个/最后一个项目
  • 使用 Enter/Space 键激活
  • 使用 Escape 键关闭(覆盖层)
  • Tab 顺序合理(每个复合控件只有一个 Tab 停靠点)
  • 对已处理的按键调用 event.preventDefault()(防止方向键触发滚动)
焦点
  • 在可聚焦元素上使用 focus-visible:focus-ring
  • 在模态框中锁定焦点(useFocusTrap)
  • 关闭覆盖层时恢复焦点(useFocusRestoration)
  • 打开覆盖层时设置初始焦点
  • 禁用项应不可聚焦,或可聚焦但不响应操作(取决于交互模式)
状态
  • 通过 useControllableState 支持受控/非受控模式
  • 属性遵循 value / defaultValue / onValueChange 约定
  • 通过数据属性暴露状态,以便 CSS 定位
  • 为动态计算值使用 CSS 变量
样式
  • 每个渲染的元素都设置 data-slot
  • 使用 cn() 合并类名,并将用户的 className 放在最后
  • 使用语义化 token(不使用原始颜色,也不使用 dark: 工具类)
  • 所有悬停状态都添加 hover-only:hover: 前缀
  • 使用 motion-safe:active:scale-[0.97] 提供按压反馈
  • 所有过渡效果都添加 motion-reduce:transition-none
  • 所有动画都添加 motion-reduce:animate-none
  • 在交互元素上添加 no-highlight
  • 在可禁用元素上添加 disabled:status-disabled
表单(如适用)
  • 使用带有 name 属性的隐藏 <input> 提交表单
  • 通过隐藏的必填输入框或约束 API 实现 required 验证
  • 被 <Field> 组件包裹时也能正常工作
  • 错误状态会响应 Field 的 data-invalid
结构
  • 将 "use client" 放在第 1 行
  • 导入顺序:react → cva → icons → cn → local
  • 仅使用命名导出,并集中在单个 export { } 代码块中
  • 不使用 React.forwardRef(通过属性或 useMergedRef 处理 ref)
  • 不使用 export default

16. 交叉引用

评论

支持 Markdown