# 拟态标签页（Tabs (APG, automatic activation)）

- 类型 / 材质：`tabs` / `neumorphic`（新拟态）
- 状态：done ｜ 最后提交：2026-10-31
- 灵感来源：APG Tabs pattern (automatic activation) + macOS segmented tabs
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/tabs/neumorphic` ｜ 条目：`/r/tabs--neumorphic.json`
- 必缺状态：焦点 / 按压 / 禁用
- 整页场景：尚未接入

## 规格

- tablist / tab / tabpanel 三件套，aria-selected + aria-controls 绑定面板
- roving tabindex：只有当前页签进 Tab 顺序
- ←→↑↓ 同时移动焦点与选中项，Home / End 到两端，禁用项跳过
- 页签条是同色内双阴影凹槽；当前页签 accent 文字 + 2px 下划线

## 参数（CSS 变量）

`--color-accent` `--color-text` `--color-text-muted` `--dur-fast` `--ease-standard` `--neuro-bg` `--neuro-dark` `--neuro-light`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `tabs` | `TabItem[]` | 必填 | — |
| `value` | `string` | 必填 | — |
| `onChange` | `(value: string) => void` | 可选 | — |
| `label` | `string` | 可选 | — |
| `children` | `ReactNode` | 可选 | — |
| `className` | `string` | 可选 | — |

## 提示词（可直接粘给智能体）

```
复现「拟态标签页」— Tabs (APG, automatic activation)（标签页 / Neumorphic）
灵感来源：APG Tabs pattern (automatic activation) + macOS segmented tabs
规格：
- tablist / tab / tabpanel 三件套，aria-selected + aria-controls 绑定面板
- roving tabindex：只有当前页签进 Tab 顺序
- ←→↑↓ 同时移动焦点与选中项，Home / End 到两端，禁用项跳过
- 页签条是同色内双阴影凹槽；当前页签 accent 文字 + 2px 下划线
参数（CSS 变量）：--color-accent --color-text --color-text-muted --dur-fast --ease-standard --neuro-bg --neuro-dark --neuro-light
必缺状态：焦点 / 按压 / 禁用
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/tabs/neumorphic · 标准 interaction /design#interaction · 调研笔记 /showcase/component/tabs/neumorphic#notes
```

## 调研笔记

import { useId, useRef, type KeyboardEvent, type ReactNode } from 'react';

export interface TabItem {
  value: string;
  label: string;
  disabled?: boolean;
}

interface Props {
  tabs: TabItem[];
  value: string;
  onChange?: (value: string) => void;
  label?: string;
  children?: ReactNode;
  className?: string;
}

/**
 * 拟态标签页：APG Tabs（automatic activation）。
 * 方向键同时移动焦点与选中项；roving tabindex 只让当前页签进 Tab 顺序。
 */
export function NeumorphicTabs({
  tabs,
  value,
  onChange,
  label = '标签页',
  children,
  className = '',
}: Props) {
  const baseId = useId();
  const tabRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const activeIndex = Math.max(0, tabs.findIndex((t) => t.value === value));
  const panelId = `${baseId}-panel`;

  const firstEnabled = () => tabs.findIndex((t) => !t.disabled);
  const lastEnabled = () => {
    for (let i = tabs.length - 1; i >= 0; i--) if (!tabs[i].disabled) return i;
    return -1;
  };

  const select = (index: number) => {
    const tab = tabs[index];
    if (!tab || tab.disabled) return;
    onChange?.(tab.value);
    tabRefs.current[index]?.focus();
  };

  const move = (dir: 1 | -1) => {
    // 从当前焦点页签出发，而不是仅从选中项出发；正常使用时两者一致
    const focused = tabRefs.current.findIndex((el) => el === document.activeElement);
    let next = focused >= 0 ? focused : activeIndex;
    for (let i = 0; i < tabs.length; i++) {
      next = (next + dir + tabs.length) % tabs.length;
      if (!tabs[next]?.disabled) break;
    }
    select(next);
  };

  const onKeyDown = (e: KeyboardEvent<HTMLButtonElement>) => {
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
      e.preventDefault();
      move(1);
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
      e.preventDefault();
      move(-1);
    } else if (e.key === 'Home') {
      e.preventDefault();
      select(firstEnabled());
    } else if (e.key === 'End') {
      e.preventDefault();
      select(lastEnabled());
    }
  };

  return (
    <div className={className}>
      <div
        role="tablist"
        aria-label={label}
        className="
          flex items-center gap-1 px-1.5 pt-1.5 rounded-t-xl
          bg-[var(--neuro-bg)]
          shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]
        "
      >
        {tabs.map((tab, index) => {
          const isActive = index === activeIndex;
          return (
            <button
              key={tab.value}
              ref={(el) => {
                tabRefs.current[index] = el;
              }}
              id={`${baseId}-tab-${index}`}
              type="button"
              role="tab"
              aria-selected={isActive}
              aria-controls={panelId}
              tabIndex={isActive ? 0 : -1}
              disabled={tab.disabled}
              onClick={() => {
                if (!tab.disabled) onChange?.(tab.value);
              }}
              onKeyDown={onKeyDown}
              className={`
                relative px-3.5 py-2 rounded-t-lg text-sm text-left
                press-feedback
                transition-colors duration-[var(--dur-fast)]
                disabled:opacity-40 disabled:cursor-not-allowed
                ${isActive
                  ? 'text-[var(--color-accent)]'
                  : 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'}
              `}
            >
              {tab.label}
              <span
                aria-hidden="true"
                className={`
                  pointer-events-none absolute left-2.5 right-2.5 bottom-0 h-0.5 rounded-full
                  bg-[var(--color-accent)] origin-left
                  transition-transform duration-[var(--dur-fast)] ease-[var(--ease-standard)]
                  ${isActive ? 'scale-x-100' : 'scale-x-0'}
                `}
              />
            </button>
          );
        })}
      </div>

      <div
        id={panelId}
        role="tabpanel"
        aria-labelledby={`${baseId}-tab-${activeIndex}`}
        tabIndex={0}
        className="pt-4 rounded-md text-sm text-[var(--color-text)]"
      >
        {children}
      </div>
    </div>
  );
}
