玻璃标签输入

tags · glass-apple · 灵感来源:GitHub issue labels / Linear 过滤器(芯片 + 文本输入)

深色模式玻璃
回车或逗号添加;退格删最后一个;失焦也会提交2 / 6
键盘可达对比度 4.8:1
点标签上的 × 移除
至少填一个命名空间
已归档
disabled 态:可见但不可改

复用卡

Tag Input (chips) · 玻璃标签输入 · 标签 / Glass

规格 · 术语提示词(给 AI 用)

做一个 Tag Input (chips)(玻璃标签输入):
- 回车 / 逗号 / 失焦提交;粘贴逗号分隔的多个值会拆开并去重
- 草稿为空时退格删除最后一个标签
- max 是硬上限:达到后占位文字改为「已达上限」,计数显示 n / max
- 芯片移除按钮 aria-label=移除 X,移除后焦点回到输入框
用这些 CSS 变量:--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-bg --glass-border --glass-highlight --glass-inset --glass-recess --text-2xs --text-base --text-xs
灵感参考:GitHub issue labels / Linear 过滤器(芯片 + 文本输入)

参数(CSS 变量)

--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-bg --glass-border --glass-highlight --glass-inset --glass-recess --text-2xs --text-base --text-xs

必缺状态

焦点 / 禁用 / 错误

整页场景

安装(一条链接)

调研笔记

玻璃标签输入

  • 提交时机三处:回车、逗号、失焦;粘贴 a, b,c 会拆成多个并去重
  • 草稿为空时按退格删掉最后一个标签(与邮件收件人一致)
  • max 是硬上限:达到后不再接受新值,占位文字改成「已达上限」,计数显示 n / max
  • 每个芯片带移除按钮,aria-label="移除 X";移除后焦点回到输入框
  • Field 负责 label / hint / error / 计数 / aria-describedby;点容器空白处聚焦输入框(不抢芯片按钮的焦点)
  • 明确不做:联想、异步候选、创建新选项——那是 combobox 的活,别在这里长出来

实现

tags/glass-apple
import { useRef, useState, type KeyboardEvent } from 'react';
import { X } from 'lucide-react';
import { Icon } from '~/components/Icon';
import { Field } from '~/components/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 GlassAppleTags({
  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-xl
            bg-[var(--glass-bg)] border backdrop-blur-xl
            shadow-[var(--glass-inset)]
            transition-[border-color,background-color] duration-[var(--dur-fast)]
            focus-within:bg-[var(--glass-highlight)]
            ${field.invalid
              ? 'border-[var(--color-warning-fg)]'
              : 'border-[var(--glass-border)] 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(--glass-highlight)] 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>
  );
}

元信息

标识
tags/glass-apple
状态
done
最后提交
2026-11-01
风格轴
glass-apple
依赖 token
--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-bg --glass-border --glass-highlight --glass-inset --glass-recess --text-2xs --text-base --text-xs
在工作台打开 源码与 notes 都在 src/components/tags/glass-apple/