# 玻璃验证码输入（One-Time Code Input）

- 类型 / 材质：`otp` / `glass-apple`（玻璃）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Apple 短信验证码自动填充实践 / GOV.UK passwords
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/otp/glass-apple` ｜ 条目：`/r/otp--glass-apple.json`
- 必缺状态：焦点 / 禁用 / 错误
- 整页场景：
  - 两步验证（`/r/scene--auth.json`）

## 规格

- 一个真输入框 + 画的格子：逐格 input 会打断 autocomplete=one-time-code 自动填充
- 输入框 opacity-0 铺在格子上，格子 aria-hidden 只反映 value
- inputmode=numeric + pattern=[0-9]* + maxLength=格数，非数字在 onChange 里丢掉
- 焦点环画在格子组上（peer-focus-visible），待填格的 accent 挂在同一个焦点上，未聚焦不留 accent
- 错误走 aria-invalid + errorMessage，文案要说清几位、多久有效

## 参数（CSS 变量）

`--color-accent` `--color-text` `--color-text-muted` `--color-text-subtle` `--color-warning-bd` `--color-warning-fg` `--glass-border` `--glass-inset` `--glass-recess` `--text-2xs` `--text-base` `--text-xs`

## Props 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `label` | `string` | 可选 | — |
| `hint` | `string` | 可选 | — |
| `error` | `boolean` | 可选 | 错误态：语义色描边 + aria-invalid |
| `errorMessage` | `string` | 可选 | 错误文案：盖过 hint 显示，并挂 role="alert" |
| `length` | `number` | 可选 | 格数，同时是最大长度 |
| `value` | `string` | 必填 | — |
| `onChange` | `(value: string) => void` | 可选 | — |
| `disabled` | `boolean` | 可选 | — |
| `autocomplete` | `'one-time-code' | 'off'` | 可选 | 系统自动填充：iOS / Android 会把短信验证码直接填进这一格 |
| `className` | `string` | 可选 | — |

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

```
复现「玻璃验证码输入」— One-Time Code Input（验证码 / Glass）
灵感来源：Apple 短信验证码自动填充实践 / GOV.UK passwords
规格：
- 一个真输入框 + 画的格子：逐格 input 会打断 autocomplete=one-time-code 自动填充
- 输入框 opacity-0 铺在格子上，格子 aria-hidden 只反映 value
- inputmode=numeric + pattern=[0-9]* + maxLength=格数，非数字在 onChange 里丢掉
- 焦点环画在格子组上（peer-focus-visible），待填格的 accent 挂在同一个焦点上，未聚焦不留 accent
- 错误走 aria-invalid + errorMessage，文案要说清几位、多久有效
参数（CSS 变量）：--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-bd --color-warning-fg --glass-border --glass-inset --glass-recess --text-2xs --text-base --text-xs
必缺状态：焦点 / 禁用 / 错误
整页场景：两步验证
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/otp/glass-apple · 标准 interaction /design#interaction · 调研笔记 /showcase/component/otp/glass-apple#notes
```

## 调研笔记

import { type ChangeEvent } from 'react';
import { Field } from '@/components/ui-echo/Field';

interface Props {
  label?: string;
  hint?: string;
  /** 错误态：语义色描边 + aria-invalid */
  error?: boolean;
  /** 错误文案：盖过 hint 显示，并挂 role="alert" */
  errorMessage?: string;
  /** 格数，同时是最大长度 */
  length?: number;
  value: string;
  onChange?: (value: string) => void;
  disabled?: boolean;
  /** 系统自动填充：iOS / Android 会把短信验证码直接填进这一格 */
  autocomplete?: 'one-time-code' | 'off';
  className?: string;
}

const onlyDigits = (raw: string, length: number) => raw.replace(/\D/g, '').slice(0, length);

/**
 * 玻璃一次性验证码：一格一个 input 是常见做法，但它会打断系统级自动填充
 * （iOS 只认单个 autocomplete="one-time-code" 的输入框），粘贴要自己分发，
 * 退格要跨格跳焦点，读屏还会逐格念。所以这里用**一个真输入框 + 画的格子**：
 * 输入框透明铺在格子上，格子只反映 value，aria-hidden。
 */
export function GlassAppleOtp({
  label,
  hint,
  error = false,
  errorMessage,
  length = 6,
  value,
  onChange,
  disabled = false,
  autocomplete = 'one-time-code',
  className = '',
}: Props) {
  const cells = Array.from({ length }, (_, i) => i);

  return (
    <Field label={label} hint={hint} error={error} errorMessage={errorMessage} className={className}>
      {(field) => (
        <div className="relative inline-block">
          <input
            id={field.id}
            type="text"
            inputMode="numeric"
            autoComplete={autocomplete}
            pattern="[0-9]*"
            maxLength={length}
            value={value}
            disabled={disabled}
            aria-invalid={field.invalid || undefined}
            aria-describedby={field.describedBy}
            aria-label={label ? undefined : hint ?? '验证码'}
            onChange={(e: ChangeEvent<HTMLInputElement>) => onChange?.(onlyDigits(e.target.value, length))}
            className="peer absolute inset-0 z-10 size-full cursor-default opacity-0 disabled:cursor-not-allowed"
          />
          <div
            aria-hidden="true"
            className={`
              flex gap-2 outline-offset-4
              peer-focus-visible:outline-2 peer-focus-visible:outline-[var(--color-accent)]
              ${disabled ? 'opacity-50' : ''}
            `}
          >
            {cells.map((i) => {
              const filled = i < value.length;
              const active = i === value.length && !disabled;
              return (
                <span
                  key={i}
                  className={`
                    grid place-items-center size-11 rounded-xl
                    font-mono text-[length:var(--text-base)] tabular-nums
                    bg-[var(--glass-recess)] border backdrop-blur-xl
                    shadow-[var(--glass-inset)]
                    ${field.invalid
                      ? 'border-[var(--color-warning-bd)]'
                      : 'border-[var(--glass-border)]'}
                    ${active ? 'peer-focus-visible:border-[var(--color-accent)]' : ''}
                    ${filled ? 'text-[var(--color-text)]' : 'text-[var(--color-text-subtle)]'}
                  `}
                >
                  {filled ? value[i] : ''}
                </span>
              );
            })}
          </div>
        </div>
      )}
    </Field>
  );
}
