Initial commit
This commit is contained in:
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
import { useState, useRef, useMemo, useEffect } from "react";
|
||||
import { X } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type ChipSuggestion =
|
||||
| string
|
||||
| { name: string; aliases?: string[]; primaryAliases?: string[] };
|
||||
|
||||
export function ChipInput({
|
||||
values,
|
||||
onChange,
|
||||
placeholder,
|
||||
accent = "cyan",
|
||||
suggestions,
|
||||
}: {
|
||||
values: string[];
|
||||
onChange: (next: string[]) => void;
|
||||
placeholder?: string;
|
||||
accent?: "cyan" | "violet";
|
||||
/** Strings or { name, aliases? } — when aliases are provided, they're matched but the chip stores `name`. */
|
||||
suggestions?: ChipSuggestion[];
|
||||
}) {
|
||||
const [draft, setDraft] = useState("");
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const blurTimerRef = useRef<number | null>(null);
|
||||
// Cancel any pending blur-commit on unmount so we don't fire setState
|
||||
// (or commit a stale draft) after the component is gone.
|
||||
useEffect(() => () => {
|
||||
if (blurTimerRef.current != null) window.clearTimeout(blurTimerRef.current);
|
||||
}, []);
|
||||
const accentVar = accent === "cyan" ? "var(--color-cyan)" : "var(--color-violet)";
|
||||
|
||||
const lowerSelected = useMemo(() => new Set(values.map((v) => v.toLowerCase())), [values]);
|
||||
|
||||
type Resolved = { name: string; matchedAlias?: string; rank: number };
|
||||
const filtered = useMemo<Resolved[]>(() => {
|
||||
if (!suggestions || !draft.trim()) return [];
|
||||
const q = draft.trim().toLowerCase();
|
||||
// rank: 0 = canonical name match, 1 = primary alias (reverse), 2 = alt alias.
|
||||
const out: Resolved[] = [];
|
||||
for (const s of suggestions) {
|
||||
const name = typeof s === "string" ? s : s.name;
|
||||
if (lowerSelected.has(name.toLowerCase())) continue;
|
||||
const primaryAliases = typeof s === "string" ? [] : (s.primaryAliases ?? []);
|
||||
const altAliases = typeof s === "string" ? [] : (s.aliases ?? []);
|
||||
if (name.toLowerCase().includes(q)) {
|
||||
out.push({ name, rank: 0 });
|
||||
continue;
|
||||
}
|
||||
const primaryHit = primaryAliases.find((a) => a.toLowerCase().includes(q));
|
||||
if (primaryHit) {
|
||||
out.push({ name, matchedAlias: primaryHit, rank: 1 });
|
||||
continue;
|
||||
}
|
||||
const altHit = altAliases.find((a) => a.toLowerCase().includes(q));
|
||||
if (altHit) out.push({ name, matchedAlias: altHit, rank: 2 });
|
||||
}
|
||||
out.sort((a, b) => a.rank - b.rank);
|
||||
return out.slice(0, 8);
|
||||
}, [suggestions, draft, lowerSelected]);
|
||||
|
||||
const showDropdown = focused && filtered.length > 0;
|
||||
|
||||
const commitValue = (raw: string) => {
|
||||
const t = raw.trim();
|
||||
if (!t) return;
|
||||
if (!lowerSelected.has(t.toLowerCase())) {
|
||||
onChange([...values, t]);
|
||||
}
|
||||
setDraft("");
|
||||
setHighlight(0);
|
||||
};
|
||||
|
||||
const commit = () => commitValue(draft);
|
||||
|
||||
const remove = (idx: number) => {
|
||||
onChange(values.filter((_, i) => i !== idx));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="flex flex-wrap items-center gap-1.5 p-2 rounded-lg glass min-h-[42px] cursor-text"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{values.map((v, i) => (
|
||||
<span
|
||||
key={`${v}-${i}`}
|
||||
className="flex items-center gap-1 pl-2 pr-1 py-0.5 rounded-full text-xs border"
|
||||
style={{
|
||||
background: `color-mix(in oklch, ${accentVar} 14%, transparent)`,
|
||||
color: accentVar,
|
||||
borderColor: `color-mix(in oklch, ${accentVar} 35%, transparent)`,
|
||||
}}
|
||||
>
|
||||
{v}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); remove(i); }}
|
||||
aria-label={`Remove ${v}`}
|
||||
className="w-4 h-4 grid place-items-center rounded-full hover:bg-black/30"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => { setDraft(e.target.value); setHighlight(0); }}
|
||||
onFocus={() => {
|
||||
if (blurTimerRef.current) { window.clearTimeout(blurTimerRef.current); blurTimerRef.current = null; }
|
||||
setFocused(true);
|
||||
}}
|
||||
onBlur={() => {
|
||||
// Delay to allow mousedown on a suggestion to fire commit before we hide.
|
||||
blurTimerRef.current = window.setTimeout(() => {
|
||||
setFocused(false);
|
||||
commit();
|
||||
}, 120);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (showDropdown && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => {
|
||||
const n = filtered.length;
|
||||
if (e.key === "ArrowDown") return (h + 1) % n;
|
||||
return (h - 1 + n) % n;
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
if (showDropdown && filtered[highlight]) {
|
||||
e.preventDefault();
|
||||
commitValue(filtered[highlight].name);
|
||||
return;
|
||||
}
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === ",") {
|
||||
e.preventDefault();
|
||||
commit();
|
||||
} else if (e.key === "Escape" && showDropdown) {
|
||||
setFocused(false);
|
||||
} else if (e.key === "Backspace" && !draft && values.length) {
|
||||
remove(values.length - 1);
|
||||
}
|
||||
}}
|
||||
placeholder={values.length === 0 ? placeholder : ""}
|
||||
className={cn(
|
||||
"flex-1 min-w-[100px] bg-transparent text-sm outline-none placeholder:text-[var(--color-fg-muted)]",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showDropdown && (
|
||||
<div
|
||||
className="absolute left-0 right-0 top-full mt-1 z-20 rounded-lg border border-[var(--color-glass-border)] bg-[var(--color-bg-0)] shadow-2xl overflow-hidden"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
{filtered.map((s, i) => (
|
||||
<button
|
||||
key={s.name}
|
||||
type="button"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
if (blurTimerRef.current) { window.clearTimeout(blurTimerRef.current); blurTimerRef.current = null; }
|
||||
commitValue(s.name);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
className={cn(
|
||||
"w-full flex items-center justify-between gap-2 px-3 py-1.5 text-sm text-left",
|
||||
i === highlight ? "bg-[var(--color-glass-strong)]" : "hover:bg-[var(--color-glass)]",
|
||||
)}
|
||||
style={{ color: accentVar }}
|
||||
>
|
||||
<span>{s.name}</span>
|
||||
{s.matchedAlias && (
|
||||
<span className="text-[10px] font-mono text-[var(--color-fg-muted)] truncate">
|
||||
↦ {s.matchedAlias}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user