# 极简输入框（Flat Minimal Input）

- 类型 / 材质：`input` / `minimal`（极简）
- 状态：done ｜ 最后提交：2026-11-01
- 灵感来源：Refactoring UI（实底 + 发丝线，不用材质做深度）
- 依据标准：`/design#interaction` ｜ 组件页：`/showcase/component/input/minimal` ｜ 条目：`/r/input--minimal.json`
- 必缺状态：焦点 / 禁用 / 错误
- 整页场景：尚未接入

## 规格

- 标签在框上方，白底 + 发丝边 + 一档软阴影
- focus 只换边框为 accent，不改变底色
- error 态：warning 描边 + aria-invalid + 文案说明
- disabled 态降透明度且不可聚焦

## 参数（CSS 变量）

`--color-accent` `--color-divider` `--color-surface` `--color-text` `--color-text-muted` `--color-text-subtle` `--color-warning-fg` `--dur-fast` `--glass-border` `--glass-recess` `--shadow-sm` `--text-2xs` `--text-base` `--text-xs`

## Props 契约

继承原生属性接口：`Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'>`（原生属性都接受，自有字段如下）

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `label` | `string` | 可选 | — |
| `hint` | `string` | 可选 | — |
| `loading` | `boolean` | 可选 | 验证中：标签右侧转圈 |
| `error` | `boolean` | 可选 | 错误态：描边转语义色 + aria-invalid（状态矩阵见 docs/standards/states.md） |
| `errorMessage` | `string` | 可选 | 错误文案：渲染在下方并接入 aria-describedby + role="alert" |
| `required` | `boolean` | 可选 | — |
| `prefix` | `ReactNode` | 可选 | — |
| `suffix` | `ReactNode` | 可选 | — |
| `clearable` | `boolean` | 可选 | 有值时显示清除按钮 |
| `reveal` | `boolean` | 可选 | password 显隐切换 |
| `showCount` | `boolean` | 可选 | 字数计数：配合 maxLength 显示 n / max |

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

```
复现「极简输入框」— Flat Minimal Input（输入 / Minimal）
灵感来源：Refactoring UI（实底 + 发丝线，不用材质做深度）
规格：
- 标签在框上方，白底 + 发丝边 + 一档软阴影
- focus 只换边框为 accent，不改变底色
- error 态：warning 描边 + aria-invalid + 文案说明
- disabled 态降透明度且不可聚焦
参数（CSS 变量）：--color-accent --color-divider --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --shadow-sm --text-2xs --text-base --text-xs
必缺状态：焦点 / 禁用 / 错误
整页场景：（尚未接进整页场景）
无障碍：焦点环 ≥2px 等效周长（SC 2.4.13）；指针目标 ≥24×24（SC 2.5.8）；动效带 prefers-reduced-motion 降级
参考：本站实样 /showcase/component/input/minimal · 标准 interaction /design#interaction · 调研笔记 /showcase/component/input/minimal#notes
```

## 调研笔记

import { useRef, useState, forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
import { Eye, EyeOff, X } from 'lucide-react';
import { Icon } from '@/components/ui-echo/Icon';
import { Field } from '@/components/ui-echo/Field';

interface Props extends Omit<InputHTMLAttributes<HTMLInputElement>, 'prefix'> {
  label?: string;
  hint?: string;
  /** 验证中：标签右侧转圈 */
  loading?: boolean;
  /** 错误态：描边转语义色 + aria-invalid（状态矩阵见 docs/standards/states.md） */
  error?: boolean;
  /** 错误文案：渲染在下方并接入 aria-describedby + role="alert" */
  errorMessage?: string;
  required?: boolean;
  prefix?: ReactNode;
  suffix?: ReactNode;
  /** 有值时显示清除按钮 */
  clearable?: boolean;
  /** password 显隐切换 */
  reveal?: boolean;
  /** 字数计数：配合 maxLength 显示 n / max */
  showCount?: boolean;
}

/** 清空需要绕过 React 的 value 追踪：直接写原生 setter 再派发 input */
const setNativeValue = (el: HTMLInputElement, value: string) => {
  const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set;
  setter?.call(el, value);
  el.dispatchEvent(new Event('input', { bubbles: true }));
};

export const MinimalInput = forwardRef<HTMLInputElement, Props>(
  (
    {
      label,
      hint,
      loading = false,
      error = false,
      errorMessage,
      required = false,
      prefix,
      suffix,
      clearable = false,
      reveal = false,
      showCount = false,
      className = '',
      id,
      value,
      defaultValue,
      onChange,
      type = 'text',
      ...rest
    },
    ref,
  ) => {
    const localRef = useRef<HTMLInputElement>(null);
    const setRef = (node: HTMLInputElement | null) => {
      localRef.current = node;
      if (typeof ref === 'function') ref(node);
      else if (ref) ref.current = node;
    };

    const [inner, setInner] = useState(String(defaultValue ?? ''));
    const [revealed, setRevealed] = useState(false);
    const text = value !== undefined ? String(value) : inner;

    return (
      <Field
        id={id}
        label={label}
        hint={hint}
        error={error}
        errorMessage={errorMessage}
        required={required}
        loading={loading}
        counter={showCount ? `${text.length}${rest.maxLength ? ` / ${rest.maxLength}` : ''}` : undefined}
        className={className}
      >
        {(field) => (
          <div
            className={`
              flex items-center
              rounded-md
              bg-[var(--color-surface)] border
              shadow-[var(--shadow-sm)]
              transition-[border-color,box-shadow] duration-[var(--dur-fast)]
              ${field.invalid
                ? 'border-[var(--color-warning-fg)]'
                : 'border-[var(--color-divider)] focus-within:border-[var(--color-accent)]'}
              ${rest.disabled ? 'opacity-50' : ''}
            `}
          >
            {prefix && (
              <span className="pl-3 shrink-0 text-[var(--color-text-muted)] text-sm">{prefix}</span>
            )}

            <input
              {...rest}
              ref={setRef}
              id={field.id}
              type={reveal && type === 'password' && revealed ? 'text' : type}
              value={value}
              defaultValue={defaultValue}
              required={required}
              aria-required={field.required || undefined}
              aria-invalid={field.invalid || undefined}
              aria-describedby={field.describedBy}
              onChange={(e) => {
                setInner(e.target.value);
                onChange?.(e);
              }}
              className={`
                w-full bg-transparent py-2.5
                ${prefix ? 'pl-2' : 'pl-4'} ${suffix || clearable || reveal ? 'pr-1' : 'pr-4'}
                text-[var(--color-text)]
                placeholder:text-[var(--color-text-subtle)]
                focus:outline-none
                disabled:cursor-not-allowed
              `}
            />

            {clearable && text !== '' && (
              <button
                type="button"
                onClick={() => localRef.current && setNativeValue(localRef.current, '')}
                aria-label="清除输入"
                className="press-feedback shrink-0 grid place-items-center size-6 rounded-full text-[var(--color-text-subtle)] hover:text-[var(--color-text)] cursor-pointer"
              >
                <Icon icon={X} size={13} />
              </button>
            )}

            {reveal && type === 'password' && (
              <button
                type="button"
                onClick={() => setRevealed((v) => !v)}
                aria-label={revealed ? '隐藏密码' : '显示密码'}
                aria-pressed={revealed}
                className="press-feedback shrink-0 grid place-items-center size-6 rounded-full text-[var(--color-text-subtle)] hover:text-[var(--color-text)] cursor-pointer"
              >
                <Icon icon={revealed ? EyeOff : Eye} size={14} />
              </button>
            )}

            {suffix && (
              <span className="pr-3 shrink-0 text-[var(--color-text-muted)] text-sm">{suffix}</span>
            )}
          </div>
        )}
      </Field>
    );
  },
);
MinimalInput.displayName = 'MinimalInput';
