# 玻璃滑块（UISlider）

- 类型 / 材质：`slider` / `glass-apple`（玻璃）
- 状态：done ｜ 最后提交：2026-10-31
- 灵感来源：iOS 设置页的 UISlider（音量 / 亮度）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/slider/glass-apple` ｜ 条目：`/r/slider--glass-apple.json`
- 必缺状态：焦点 / 禁用
- 整页场景：
  - 账户设置（`/r/scene--page-settings.json`）

## 规格

- role=slider + aria-valuenow/min/max，← → 单步、PageUp/Down 十步、Home/End 到端
- 拖拽 1:1 跟手，不挂过渡；值吸附到 step 后再提交
- 凹陷槽 + accent 已走段 + 白面圆钮，与开关同一套材质语言
- 拖拽区两端各让出半个旋钮，0 / 100 时旋钮不出槽

## 参数（CSS 变量）

`--color-accent` `--color-surface` `--color-text-muted` `--glass-border` `--glass-recess` `--shadow-sm`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `value` | `number` | 可选 | — |
| `defaultValue` | `number` | 可选 | — |
| `min` | `number` | 可选 | — |
| `max` | `number` | 可选 | — |
| `step` | `number` | 可选 | — |
| `onChange` | `(value: number) => void` | 可选 | — |
| `disabled` | `boolean` | 可选 | — |
| `className` | `string` | 可选 | — |
| `showValue` | `boolean` | 可选 | — |
| `unit` | `string` | 可选 | — |

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

```
复现「玻璃滑块」— UISlider（滑块 / Glass）
灵感来源：iOS 设置页的 UISlider（音量 / 亮度）
规格：
- role=slider + aria-valuenow/min/max，← → 单步、PageUp/Down 十步、Home/End 到端
- 拖拽 1:1 跟手，不挂过渡；值吸附到 step 后再提交
- 凹陷槽 + accent 已走段 + 白面圆钮，与开关同一套材质语言
- 拖拽区两端各让出半个旋钮，0 / 100 时旋钮不出槽
参数（CSS 变量）：--color-accent --color-surface --color-text-muted --glass-border --glass-recess --shadow-sm
必缺状态：焦点 / 禁用
整页场景：账户设置
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/slider/glass-apple · 标准 interaction /design#interaction · 调研笔记 /showcase/component/slider/glass-apple#notes
```

## 调研笔记

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

interface Props {
  value?: number;
  defaultValue?: number;
  min?: number;
  max?: number;
  step?: number;
  onChange?: (value: number) => void;
  disabled?: boolean;
  className?: string;
  showValue?: boolean;
  unit?: string;
  'aria-label'?: string;
}

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));

/** 旋钮 20px：拖拽区两端各让出半个旋钮，值走到 0 / 100 时旋钮不出槽 */
const THUMB = 20;

export function GlassAppleSlider({
  value,
  defaultValue,
  min = 0,
  max = 100,
  step = 1,
  onChange,
  disabled,
  className = '',
  showValue,
  unit = '',
  ...rest
}: Props) {
  const isControlled = value !== undefined;
  const [internal, setInternal] = useState(defaultValue ?? min);
  const current = clamp(isControlled ? value : internal, min, max);
  const pct = max === min ? 0 : ((current - min) / (max - min)) * 100;
  const travelRef = useRef<HTMLDivElement>(null);

  const commit = (next: number) => {
    const snapped = clamp(min + Math.round((next - min) / step) * step, min, max);
    if (!isControlled) setInternal(snapped);
    if (snapped !== current) onChange?.(snapped);
  };

  /** 跟手 1:1：位置直接由指针算值，不做阻尼、不挂过渡（interaction.md §4） */
  const fromPointer = (clientX: number) => {
    const el = travelRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    if (rect.width === 0) return;
    commit(min + ((clientX - rect.left) / rect.width) * (max - min));
  };

  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
    if (disabled) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    fromPointer(e.clientX);
  };

  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    if (disabled || !e.currentTarget.hasPointerCapture(e.pointerId)) return;
    fromPointer(e.clientX);
  };

  /** APG Slider：左右 / 上下步进，PageUp/Down 十步，Home/End 到端 */
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (disabled) return;
    const big = step * 10;
    const targets: Record<string, number> = {
      ArrowRight: current + step,
      ArrowUp: current + step,
      ArrowLeft: current - step,
      ArrowDown: current - step,
      PageUp: current + big,
      PageDown: current - big,
      Home: min,
      End: max,
    };
    const next = targets[e.key];
    if (next === undefined) return;
    e.preventDefault();
    commit(next);
  };

  return (
    <div className={`flex items-center gap-3 ${className}`}>
      <div
        className={`
          relative h-6 flex-1 select-none
          ${disabled ? 'opacity-40 cursor-not-allowed' : 'cursor-pointer'}
        `}
      >
        <div className="absolute inset-x-0 top-1/2 h-1.5 -translate-y-1/2 rounded-full bg-[var(--glass-recess)] border border-[var(--glass-border)]" />

        <div
          ref={travelRef}
          onPointerDown={onPointerDown}
          onPointerMove={onPointerMove}
          className="absolute inset-y-0 touch-none"
          style={{ left: THUMB / 2, right: THUMB / 2 }}
        >
          <div
            className="absolute top-1/2 left-0 h-1.5 -translate-y-1/2 rounded-full bg-[var(--color-accent)]"
            style={{ width: `${pct}%` }}
          />
          <div
            role="slider"
            tabIndex={disabled ? -1 : 0}
            aria-valuemin={min}
            aria-valuemax={max}
            aria-valuenow={current}
            aria-orientation="horizontal"
            aria-disabled={disabled || undefined}
            onKeyDown={onKeyDown}
            className="
              absolute top-1/2 size-5 -translate-x-1/2 -translate-y-1/2 rounded-full
              bg-[var(--color-surface)] border border-[var(--glass-border)]
              shadow-[var(--shadow-sm)]
            "
            style={{ left: `${pct}%` }}
            {...rest}
          />
        </div>
      </div>

      {showValue && (
        <span className="w-10 shrink-0 text-right text-xs font-mono text-[var(--color-text-muted)]">
          {current}{unit}
        </span>
      )}
    </div>
  );
}
