tags · neumorphic · 灵感来源:Neumorphism(凹槽 + 同色芯片)
Tag Input (chips) · 拟态标签输入 · 标签 / Neumorphic
做一个 Tag Input (chips)(拟态标签输入):
- 同一份 props(value / defaultValue / onChange / max / disabled)
- 外框凹槽(内阴影)+ 芯片浮起(外阴影),方向相反才读得出层次
- 提交时机与去重、上限规则同玻璃标签输入
用这些 CSS 变量:--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --neuro-bg --neuro-dark --neuro-light --text-2xs --text-base --text-xs
灵感参考:Neumorphism(凹槽 + 同色芯片)--color-accent --color-text --color-text-muted --color-text-subtle --color-warning-fg --dur-fast --glass-border --glass-recess --neuro-bg --neuro-dark --neuro-light --text-2xs --text-base --text-xs
焦点 / 禁用 / 错误
(尚未接进整页场景)
/r/tags--neumorphic.json
免外壳的 markdown 版:/llms/components/tags--neumorphic.mdimport { 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 NeumorphicTags({
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-lg
bg-[var(--neuro-bg)] border border-transparent
shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light)]
transition-[border-color,box-shadow] duration-[var(--dur-fast)]
${field.invalid
? 'border-[var(--color-warning-fg)]'
: 'focus-within:shadow-[inset_2px_2px_5px_var(--neuro-dark),inset_-2px_-2px_5px_var(--neuro-light),0_0_0_2px_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(--neuro-bg)] shadow-[1px_1px_2px_var(--neuro-dark),-1px_-1px_2px_var(--neuro-light)] 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>
);
}
src/components/tags/neumorphic/