# 极简标签输入（Tag Input (chips)）

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

## 规格

- 同一份 props（value / defaultValue / onChange / max / disabled）
- 实底 + 发丝边；芯片用页面底色加边框区分
- 提交时机与去重、上限规则同玻璃标签输入

## 参数（CSS 变量）

`--color-accent` `--color-bg` `--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 契约

| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `label` | `string` | 可选 | — |
| `hint` | `string` | 可选 | — |
| `error` | `boolean` | 可选 | — |
| `errorMessage` | `string` | 可选 | — |
| `required` | `boolean` | 可选 | — |
| `value` | `string[]` | 可选 | — |
| `defaultValue` | `string[]` | 可选 | — |
| `onChange` | `(next: string[]) => void` | 可选 | — |
| `placeholder` | `string` | 可选 | — |
| `max` | `number` | 可选 | 最多几个；达到上限后不再接受新值 |
| `disabled` | `boolean` | 可选 | — |
| `className` | `string` | 可选 | — |

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

```
复现「极简标签输入」— Tag Input (chips)（标签 / Minimal）
灵感来源：Refactoring UI（实底 + 发丝边）
规格：
- 同一份 props（value / defaultValue / onChange / max / disabled）
- 实底 + 发丝边；芯片用页面底色加边框区分
- 提交时机与去重、上限规则同玻璃标签输入
参数（CSS 变量）：--color-accent --color-bg --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/tags/minimal · 标准 interaction /design#interaction · 调研笔记 /showcase/component/tags/minimal#notes
```

## 调研笔记

import { useRef, useState, type KeyboardEvent } from 'react';
import { X } from 'lucide-react';
import { Icon } from '@/components/ui-echo/Icon';
import { Field } from '@/components/ui-echo/Field';

interface Props {
  label?: string;
  hint?: string;
  error?: boolean;
  errorMessage?: string;
  required?: boolean;
  value?: string[];
  defaultValue?: string[];
  onChange?: (next: string[]) => void;
  placeholder?: string;
  /** 最多几个；达到上限后不再接受新值 */
  max?: number;
  disabled?: boolean;
  className?: string;
}

export function MinimalTags({
  label,
  hint,
  error = false,
  errorMessage,
  required = false,
  value,
  defaultValue,
  onChange,
  placeholder = '输入后回车',
  max,
  disabled = false,
  className = '',
}: Props) {
  const [inner, setInner] = useState<string[]>(defaultValue ?? []);
  const [draft, setDraft] = useState('');
  const inputRef = useRef<HTMLInputElement>(null);
  const tags = value ?? inner;
  const full = max !== undefined && tags.length >= max;

  const update = (next: string[]) => {
    setInner(next);
    onChange?.(next);
  };

  /** 回车 / 逗号 / 失焦都走这里：一次可粘贴多个，去重并守住上限 */
  const commit = (raw: string) => {
    const parts = raw
      .split(/[,，]/)
      .map((s) => s.trim())
      .filter(Boolean);
    if (!parts.length) return;
    const next = [...tags];
    for (const part of parts) {
      if (max !== undefined && next.length >= max) break;
      if (!next.includes(part)) next.push(part);
    }
    update(next);
    setDraft('');
  };

  const remove = (tag: string) => {
    if (disabled) return;
    update(tags.filter((t) => t !== tag));
    inputRef.current?.focus();
  };

  const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
    if (e.key === 'Enter' || e.key === ',' || e.key === '，') {
      e.preventDefault();
      commit(draft);
    } else if (e.key === 'Backspace' && draft === '' && tags.length) {
      e.preventDefault();
      update(tags.slice(0, -1));
    }
  };

  return (
    <Field
      label={label}
      hint={hint}
      error={error}
      errorMessage={errorMessage}
      required={required}
      counter={max !== undefined ? `${tags.length} / ${max}` : undefined}
      className={className}
    >
      {(field) => (
        <div
          onPointerDown={(e) => {
            // 点空白处也聚焦输入框；点在移除按钮上不抢焦点
            if (e.target === e.currentTarget) inputRef.current?.focus();
          }}
          className={`
            flex flex-wrap items-center gap-1.5
            px-2 py-1.5 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)]'}
            ${disabled ? 'opacity-50' : ''}
          `}
        >
          {tags.map((tag) => (
            <span
              key={tag}
              className="
                inline-flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-md
                text-[length:var(--text-2xs)] font-mono
                bg-[var(--color-bg)] text-[var(--color-text)]
              "
            >
              {tag}
              <button
                type="button"
                onClick={() => remove(tag)}
                disabled={disabled}
                aria-label={`移除 ${tag}`}
                className="press-feedback grid place-items-center size-4 rounded text-[var(--color-text-subtle)] hover:text-[var(--color-text)] cursor-pointer disabled:cursor-not-allowed"
              >
                <Icon icon={X} size={10} />
              </button>
            </span>
          ))}

          <input
            ref={inputRef}
            id={field.id}
            value={draft}
            disabled={disabled}
            placeholder={full ? '已达上限' : placeholder}
            aria-required={field.required || undefined}
            aria-invalid={field.invalid || undefined}
            aria-describedby={field.describedBy}
            onChange={(e) => setDraft(e.target.value)}
            onKeyDown={onKeyDown}
            onBlur={() => commit(draft)}
            className="
              flex-1 min-w-[96px] bg-transparent px-1.5 py-1
              text-sm text-[var(--color-text)]
              placeholder:text-[var(--color-text-subtle)]
              focus:outline-none disabled:cursor-not-allowed
            "
          />
        </div>
      )}
    </Field>
  );
}
