# 拟态分段控件（Neumorphic Segmented Control）

- 类型 / 材质：`segmented` / `neumorphic`（新拟态）
- 状态：done ｜ 最后提交：2026-10-31
- 灵感来源：Neumorphism（轨道压入、滑块浮起，共用一组同色双阴影）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/segmented/neumorphic` ｜ 条目：`/r/segmented--neumorphic.json`
- 必缺状态：焦点 / 禁用
- 整页场景：尚未接入

## 规格

- APG radiogroup：方向键遍历 + roving tabindex
- 轨道压入（inset 双阴影），选中滑块同色浮起（外双阴影）
- 滑块 translate 平移，切换时一次 squash & stretch
- 选项可单独禁用，禁用项不响应方向键

## 参数（CSS 变量）

`--color-text` `--color-text-muted` `--color-text-subtle` `--dur-base` `--dur-fast` `--ease-standard` `--fw-medium` `--fw-normal` `--neuro-bg` `--neuro-dark` `--neuro-light` `--text-sm` `--text-xs`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `options` | `SegmentOption[]` | 必填 | — |
| `value` | `string` | 必填 | — |
| `onChange` | `(value: string) => void` | 可选 | — |
| `size` | `'sm' | 'md'` | 可选 | — |
| `className` | `string` | 可选 | — |

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

```
复现「拟态分段控件」— Neumorphic Segmented Control（分段 / Neumorphic）
灵感来源：Neumorphism（轨道压入、滑块浮起，共用一组同色双阴影）
规格：
- APG radiogroup：方向键遍历 + roving tabindex
- 轨道压入（inset 双阴影），选中滑块同色浮起（外双阴影）
- 滑块 translate 平移，切换时一次 squash & stretch
- 选项可单独禁用，禁用项不响应方向键
参数（CSS 变量）：--color-text --color-text-muted --color-text-subtle --dur-base --dur-fast --ease-standard --fw-medium --fw-normal --neuro-bg --neuro-dark --neuro-light --text-sm --text-xs
必缺状态：焦点 / 禁用
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/segmented/neumorphic · 标准 interaction /design#interaction · 调研笔记 /showcase/component/segmented/neumorphic#notes
```

## 调研笔记

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

export interface SegmentOption {
  value: string;
  label: ReactNode;
  /** 禁用项：不可选也不响应方向键 */
  disabled?: boolean;
}

interface Props {
  options: SegmentOption[];
  value: string;
  onChange?: (value: string) => void;
  size?: 'sm' | 'md';
  className?: string;
}

const sizeClasses = {
  sm: 'h-8 text-[length:var(--text-xs)] px-2.5',
  md: 'h-9 text-[length:var(--text-sm)] px-3.5',
};

/**
 * 拟态分段控件：与玻璃分段同一份 props，
 * 轨道是一道压进去的槽，滑块从槽里浮起来——两个方向同用一组 --neuro 阴影。
 */
export function NeumorphicSegmented({
  options,
  value,
  onChange,
  size = 'md',
  className = '',
}: Props) {
  const index = Math.max(0, options.findIndex((o) => o.value === value));
  const [stretching, setStretching] = useState(false);
  const btnRefs = useRef<(HTMLButtonElement | null)[]>([]);

  const select = (next: number, focus = false) => {
    const clamped = Math.max(0, Math.min(options.length - 1, next));
    if (options[clamped]?.disabled) return;
    if (clamped !== index) {
      setStretching(true);
      window.setTimeout(() => setStretching(false), 280);
    }
    onChange?.(options[clamped].value);
    if (focus) btnRefs.current[clamped]?.focus();
  };

  const onKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
      e.preventDefault();
      select(index + 1, true);
    } else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') {
      e.preventDefault();
      select(index - 1, true);
    } else if (e.key === 'Home') {
      e.preventDefault();
      select(0, true);
    } else if (e.key === 'End') {
      e.preventDefault();
      select(options.length - 1, true);
    }
  };

  return (
    <div
      role="radiogroup"
      onKeyDown={onKeyDown}
      className={`
        relative grid items-stretch
        rounded-lg p-1
        bg-[var(--neuro-bg)]
        shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]
        ${className}
      `}
      style={{ gridTemplateColumns: `repeat(${options.length}, 1fr)` }}
    >
      {/* 滑块：同色浮起，小阴影成对（亮侧 + 暗侧） */}
      <span
        aria-hidden="true"
        className={`
          absolute top-1 bottom-1 left-1 rounded-md
          bg-[var(--neuro-bg)]
          shadow-[3px_3px_7px_var(--neuro-dark),-3px_-3px_7px_var(--neuro-light)]
          ${stretching ? 'segmented-stretch' : ''}
        `}
        style={{
          width: `calc((100% - 8px) / ${options.length})`,
          translate: `${index * 100}% 0`,
          transition: 'translate var(--dur-base) var(--ease-standard)',
        }}
      />

      {options.map((opt, i) => {
        const selected = i === index;
        return (
          <button
            key={opt.value}
            ref={(el) => { btnRefs.current[i] = el; }}
            type="button"
            role="radio"
            aria-checked={selected}
            tabIndex={selected ? 0 : -1}
            onClick={() => select(i)}
            disabled={opt.disabled}
            aria-disabled={opt.disabled}
            className={`
              relative z-[1] rounded-md whitespace-nowrap
              ${sizeClasses[size]}
              transition-colors duration-[var(--dur-fast)]
              select-none
              ${opt.disabled
                ? 'text-[var(--color-text-subtle)] cursor-not-allowed opacity-50'
                : `cursor-pointer ${selected
                    ? 'text-[var(--color-text)]'
                    : 'text-[var(--color-text-muted)] hover:text-[var(--color-text)]'}`}
            `}
            style={{ fontWeight: selected ? 'var(--fw-medium)' : 'var(--fw-normal)' }}
          >
            {opt.label}
          </button>
        );
      })}
    </div>
  );
}
