"use client"; import { useRef, useState } from "react"; import { XIcon } from "lucide-react"; import { cn } from "@/lib/utils"; /** * A list of short strings, edited as chips. * * Three places in this app stored a `String[]` and edited it as one comma-joined * text box — role tags, skill groups on Me, skill groups on a resume. That box * reads as a sentence with commas in it rather than as a list, so nothing looks * like a value you can remove, a stray comma silently splits one entry into two, * and a long list becomes a single overflowing line you have to scrub through to * find anything. * * The values are still a `String[]` and still joined by whoever renders them — * the resume prints "React, TypeScript, Go" exactly as before. This is the * editing surface only, which is the direct-manipulation exception the working * agreement carves out. No tool changes, because `update_role` and * `update_extra` already take the array. * * Deliberately not the tag picker. That one is backed by the Tag table: rows * with ids, kinds and colours, shared across companies, contacts and * applications. These are free text with no catalogue behind them, and giving * them a picker would mean inventing one — the working agreement's "don't add a * second labelling mechanism" cuts both ways. */ export function ChipInput({ values, onChange, placeholder, id, /** What one entry is called. Names the box for a screen reader. */ noun = "item", }: { values: string[]; onChange: (next: string[]) => void; placeholder?: string; id?: string; noun?: string; }) { const [draft, setDraft] = useState(""); const boxRef = useRef(null); /** * Commit whatever is typed. Splits on commas too, so pasting a comma-joined * list from wherever they kept it before lands as chips rather than as one * long chip — which is exactly what people will do the first time. */ const commit = (text: string) => { const added = text .split(",") .map((part) => part.trim()) .filter(Boolean) // Case-insensitive, because "Python" and "python" are one skill and // having both on a resume looks like a mistake. .filter( (part, index, all) => all.findIndex((other) => other.toLowerCase() === part.toLowerCase()) === index && !values.some((existing) => existing.toLowerCase() === part.toLowerCase()), ); if (added.length > 0) onChange([...values, ...added]); setDraft(""); }; const remove = (index: number) => { onChange(values.filter((_, i) => i !== index)); }; return (
boxRef.current?.focus()} > {values.map((value, index) => ( {value} ))} , so the box names itself rather than being announced // as an unlabelled text field. aria-label={`Add a ${noun}`} value={draft} onChange={(event) => { // A typed comma commits rather than being stored, so the separator // never survives into a value. if (event.target.value.includes(",")) commit(event.target.value); else setDraft(event.target.value); }} onKeyDown={(event) => { if (event.key === "Enter") { // Never let this submit a surrounding form by accident. event.preventDefault(); commit(draft); return; } // Backspace on an empty box takes the last chip off, which is what // every chip input anybody has used does. if (event.key === "Backspace" && !draft && values.length > 0) { event.preventDefault(); remove(values.length - 1); } }} // Leaving the field commits what is sitting in it. Losing a typed word // because you clicked away is the most annoying thing this could do. onBlur={() => commit(draft)} placeholder={values.length === 0 ? placeholder : ""} // text-base below md is not a style choice — iOS Safari zooms the // viewport when you focus a field under 16px and never zooms back. // The Input primitive carries the same comment for the same reason. className="placeholder:text-faint min-w-24 flex-1 bg-transparent px-1 py-0.5 text-base outline-none md:text-sm" />
); }