"use client";
import * as React from "react";
import { AnimatePresence, animate, motion } from "motion/react";
function subscribeReducedMotion(callback: () => void) {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
mq.addEventListener("change", callback);
return () => mq.removeEventListener("change", callback);
}
/** SSR-safe: hydrates as "no preference", then follows the media query. */
function usePrefersReducedMotion() {
return React.useSyncExternalStore(
subscribeReducedMotion,
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
() => false,
);
}
export interface ComboboxOption {
value: string;
label: string;
/** Options sharing a group are rendered under one labelled heading (in first-seen order). */
group?: string;
/** Secondary text shown on the right of the option. */
description?: string;
disabled?: boolean;
}
export interface ComboboxProps
extends Omit<React.ComponentPropsWithoutRef<"div">, "onChange" | "defaultValue" | "children"> {
/** Static options, filtered on the client by case-insensitive substring. */
options?: ComboboxOption[];
/**
* Async source. Called (debounced) with the query and an AbortSignal that fires when a newer
* query supersedes it or the list closes. When set, `options` is ignored.
*/
loadOptions?: (query: string, signal: AbortSignal) => Promise<ComboboxOption[]>;
/** Debounce before `loadOptions` runs, in ms. */
debounce?: number;
/** Allow several values, rendered as removable chips. */
multiple?: boolean;
/** Offer a "Create …" option when the query matches nothing exactly. */
creatable?: boolean;
/** Turns a created label into an option (e.g. to assign an id). Defaults to `{ value: label, label }`. */
onCreate?: (label: string) => ComboboxOption | void;
/** Controlled selection. Single mode holds at most one value. */
value?: string[];
/** Initial selection when uncontrolled. */
defaultValue?: string[];
/** Called with the selected values and their options after every change. */
onChange?: (values: string[], options: ComboboxOption[]) => void;
/** Visible label, rendered as a real <label> for the input. */
label?: React.ReactNode;
/** Helper text below the field (linked via aria-describedby). */
hint?: React.ReactNode;
/** Error message. Any truthy value marks the field invalid; a node is also shown and announced. */
error?: React.ReactNode;
placeholder?: string;
/** Text shown when nothing matches. */
emptyMessage?: string;
/** Focus ring, caret, match underline and highlight colour (any CSS colour). */
accent?: string;
disabled?: boolean;
/** When set, hidden inputs submit each selected value under this name in native forms. */
name?: string;
/** id for the input (generated when omitted). */
inputId?: string;
}
type AsyncResult = { query: string; items: ComboboxOption[]; error: boolean };
const norm = (s: string) => s.trim().toLowerCase();
/** Renders `text` with the first case-insensitive occurrence of `query` marked. */
function Highlight({ text, query }: { text: string; query: string }) {
const at = query ? text.toLowerCase().indexOf(query) : -1;
if (at === -1) return <>{text}</>;
return (
<>
{text.slice(0, at)}
<mark className="bg-transparent font-semibold text-zinc-950 underline decoration-[var(--cb-accent)] decoration-2 underline-offset-[3px] dark:text-white">
{text.slice(at, at + query.length)}
</mark>
{text.slice(at + query.length)}
</>
);
}
/**
* An editable combobox following the ARIA 1.2 pattern: the input owns a listbox through
* aria-controls/aria-expanded and tracks the highlighted option with aria-activedescendant, so
* DOM focus never leaves the text field. Static options are filtered with match highlighting;
* `loadOptions` adds debounced, cancellable async search with loading, error and empty states.
*/
export function Combobox({
options,
loadOptions,
debounce = 250,
multiple = false,
creatable = false,
onCreate,
value,
defaultValue,
onChange,
label = "Search",
hint,
error,
placeholder = "Type to search…",
emptyMessage = "No matches",
accent = "#FF7448",
disabled = false,
name,
inputId: inputIdProp,
className = "",
style,
...props
}: ComboboxProps) {
const reduceMotion = usePrefersReducedMotion();
const uid = React.useId();
const inputId = inputIdProp ?? `cb-${uid}`;
const listboxId = `${inputId}-listbox`;
const hintId = `${inputId}-hint`;
const errorId = `${inputId}-error`;
const optionId = (i: number) => `${inputId}-opt-${i}`;
const [inner, setInner] = React.useState<string[]>(() => defaultValue ?? []);
const selected = value ?? inner;
const [text, setText] = React.useState("");
/** True while the input holds a query typed by the user (single mode otherwise shows the selection). */
const [dirty, setDirty] = React.useState(false);
const [open, setOpen] = React.useState(false);
const [activeIndex, setActiveIndex] = React.useState(-1);
const [created, setCreated] = React.useState<ComboboxOption[]>([]);
const [known, setKnown] = React.useState<Record<string, ComboboxOption>>({});
const [asyncResult, setAsyncResult] = React.useState<AsyncResult | null>(null);
const [announcement, setAnnouncement] = React.useState("");
const inputRef = React.useRef<HTMLInputElement>(null);
const listRef = React.useRef<HTMLDivElement>(null);
const highlightRef = React.useRef<HTMLDivElement>(null);
const isAsync = typeof loadOptions === "function";
const invalid = Boolean(error);
const hasErrorText = invalid && error !== true;
const findOption = (v: string): ComboboxOption =>
options?.find((o) => o.value === v) ??
created.find((o) => o.value === v) ??
asyncResult?.items.find((o) => o.value === v) ??
known[v] ?? { value: v, label: v };
const selectedOptions = selected.map(findOption);
const singleLabel = !multiple && selectedOptions[0] ? selectedOptions[0].label : "";
const inputValue = multiple || dirty ? text : singleLabel;
const rawQuery = multiple || dirty ? text : "";
const query = norm(rawQuery);
/* ------------------------------- async search ------------------------------ */
const runLoad = React.useEffectEvent((q: string, signal: AbortSignal) => loadOptions?.(q, signal));
React.useEffect(() => {
if (!isAsync || !open || disabled) return;
const controller = new AbortController();
const timer = setTimeout(() => {
const pending = runLoad(rawQuery, controller.signal);
if (!pending) return;
pending.then(
(items) => {
if (!controller.signal.aborted) setAsyncResult({ query: rawQuery, items, error: false });
},
() => {
if (!controller.signal.aborted) setAsyncResult({ query: rawQuery, items: [], error: true });
},
);
}, debounce);
// A newer query (or closing) cancels both the pending debounce and the in-flight request.
return () => {
clearTimeout(timer);
controller.abort();
};
}, [isAsync, open, disabled, rawQuery, debounce]);
// Results are "loading" until a response for exactly this query has arrived.
const loading = isAsync && open && asyncResult?.query !== rawQuery;
const loadFailed = isAsync && !loading && asyncResult?.error === true;
/* ------------------------------ derived options ----------------------------- */
const source: ComboboxOption[] = isAsync
? (asyncResult?.items ?? [])
: [...(options ?? []), ...created].filter((o) => !query || o.label.toLowerCase().includes(query));
const allLabels = isAsync ? source : [...(options ?? []), ...created];
const canCreate = creatable && query !== "" && !loading && !allLabels.some((o) => norm(o.label) === query);
// Group in first-seen order, keeping a flat index for keyboard navigation and ids.
const groups: { name: string | undefined; items: { option: ComboboxOption; index: number }[] }[] = [];
const flat: ComboboxOption[] = [];
for (const option of source) {
let g = groups.find((x) => x.name === option.group);
if (!g) {
g = { name: option.group, items: [] };
groups.push(g);
}
g.items.push({ option, index: -1 });
}
for (const g of groups) for (const item of g.items) item.index = flat.push(item.option) - 1;
const createIndex = canCreate ? flat.length : -1;
const count = flat.length + (canCreate ? 1 : 0);
const isEnabled = (i: number) => i === createIndex || (i >= 0 && i < flat.length && !flat[i].disabled);
const listOpen = open && !disabled;
const active = listOpen && activeIndex < count && isEnabled(activeIndex) ? activeIndex : -1;
/* --------------------------------- helpers -------------------------------- */
const announce = (message: string) => setAnnouncement((prev) => (prev === message ? `${message} ` : message));
const commit = (next: string[], extra: ComboboxOption[] = []) => {
if (extra.length) setKnown((k) => ({ ...k, ...Object.fromEntries(extra.map((o) => [o.value, o])) }));
if (value === undefined) setInner(next);
const lookup = (v: string) => extra.find((o) => o.value === v) ?? findOption(v);
onChange?.(next, next.map(lookup));
};
const scrollToOption = (index: number) => {
const list = listRef.current;
const el = list?.querySelector<HTMLElement>(`#${CSS.escape(optionId(index))}`);
if (!list || !el) return;
// Scroll the listbox only — scrollIntoView would also move the page.
if (el.offsetTop < list.scrollTop + 4) list.scrollTop = el.offsetTop - 4;
else if (el.offsetTop + el.offsetHeight > list.scrollTop + list.clientHeight - 4)
list.scrollTop = el.offsetTop + el.offsetHeight - list.clientHeight + 4;
};
const setActive = (index: number) => {
setActiveIndex(index);
requestAnimationFrame(() => scrollToOption(index));
};
/** Next enabled index from `from` in `step` direction, wrapping around. */
const step = (from: number, delta: 1 | -1) => {
for (let n = 1; n <= count; n++) {
const i = (((from + delta * n) % count) + count) % count;
if (isEnabled(i)) return i;
}
return -1;
};
const edge = (end: "first" | "last") => (end === "first" ? step(-1, 1) : step(count, -1));
const openList = (highlight?: "first" | "last" | "selected") => {
if (disabled) return;
setOpen(true);
if (!highlight || count === 0) return;
const sel = highlight === "selected" ? flat.findIndex((o) => selected.includes(o.value)) : -1;
setActive(sel !== -1 && isEnabled(sel) ? sel : edge(highlight === "last" ? "last" : "first"));
};
const closeList = () => {
setOpen(false);
setActiveIndex(-1);
};
const chooseOption = (option: ComboboxOption) => {
if (multiple) {
const has = selected.includes(option.value);
commit(has ? selected.filter((v) => v !== option.value) : [...selected, option.value], [option]);
announce(`${option.label} ${has ? "removed" : "added"}. ${has ? selected.length - 1 : selected.length + 1} selected.`);
setText("");
setDirty(false);
} else {
commit([option.value], [option]);
announce(`${option.label} selected.`);
setText("");
setDirty(false);
closeList();
}
};
const createFromQuery = () => {
const labelText = rawQuery.trim();
if (!labelText) return;
const option = onCreate?.(labelText) ?? { value: labelText, label: labelText };
setCreated((c) => (c.some((o) => o.value === option.value) ? c : [...c, option]));
chooseOption(option);
if (multiple) setActiveIndex(-1);
};
const chooseIndex = (index: number) => {
if (index === createIndex) createFromQuery();
else if (flat[index] && !flat[index].disabled) chooseOption(flat[index]);
};
const removeValue = (v: string) => {
if (disabled) return;
const option = findOption(v);
const next = selected.filter((x) => x !== v);
commit(next);
announce(`${option.label} removed. ${next.length} selected.`);
inputRef.current?.focus();
};
/** Single mode: settle the field when focus leaves or Escape is pressed while closed. */
const settleSingle = () => {
if (multiple || !dirty) return;
const typed = norm(text);
if (!typed) {
if (selected.length) {
commit([]);
announce("Selection cleared.");
}
} else {
const exact = [...(options ?? []), ...created, ...(asyncResult?.items ?? [])].find(
(o) => !o.disabled && norm(o.label) === typed,
);
if (exact && exact.value !== selected[0]) commit([exact.value], [exact]);
}
setText("");
setDirty(false);
};
/* ------------------------------ highlight pill ------------------------------ */
// The highlight is one absolutely positioned layer that glides between options.
React.useLayoutEffect(() => {
const pill = highlightRef.current;
const list = listRef.current;
if (!pill || !list) return;
const el = active === -1 ? null : list.querySelector<HTMLElement>(`#${CSS.escape(optionId(active))}`);
if (!el) {
pill.style.opacity = "0";
return;
}
const wasHidden = pill.style.opacity !== "1";
pill.style.opacity = "1";
// Appearing (or reduced motion) snaps into place; moving between options glides.
const controls = animate(
pill,
{ y: el.offsetTop, height: el.offsetHeight },
reduceMotion || wasHidden ? { duration: 0 } : { type: "spring", stiffness: 700, damping: 46, mass: 0.6 },
);
return () => controls.stop();
});
/* --------------------------------- keyboard -------------------------------- */
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.nativeEvent.isComposing) return;
switch (event.key) {
case "ArrowDown":
event.preventDefault();
if (!listOpen) openList(event.altKey ? undefined : "selected");
else if (!event.altKey && count) setActive(step(active, 1));
break;
case "ArrowUp":
event.preventDefault();
if (event.altKey) closeList();
else if (!listOpen) openList("last");
else if (count) setActive(step(active === -1 ? 0 : active, -1));
break;
case "Home":
case "End":
// While navigating the list, Home/End jump to the first/last option; otherwise they move the caret.
if (listOpen && active !== -1) {
event.preventDefault();
setActive(edge(event.key === "Home" ? "first" : "last"));
}
break;
case "PageDown":
case "PageUp":
if (listOpen && count) {
event.preventDefault();
// Jump eight options (without wrapping), then settle on the nearest enabled one.
const dir = event.key === "PageDown" ? 1 : -1;
let i = Math.min(count - 1, Math.max(0, (active === -1 ? 0 : active) + dir * 8));
while (i >= 0 && i < count && !isEnabled(i)) i += dir;
setActive(i >= 0 && i < count ? i : edge(dir === 1 ? "last" : "first"));
}
break;
case "Enter":
if (listOpen && active !== -1) {
event.preventDefault();
chooseIndex(active);
} else if (listOpen && canCreate && count === 1) {
event.preventDefault();
createFromQuery();
}
break;
case "Escape":
if (listOpen) {
event.preventDefault();
closeList();
} else if (dirty || text) {
event.preventDefault();
setText("");
setDirty(false);
} else if (!multiple && selected.length) {
// A second Escape on an untouched single field clears the selection.
event.preventDefault();
commit([]);
announce("Selection cleared.");
}
break;
case "Tab":
// Tab accepts the highlighted option in single mode, then focus moves on as normal.
if (listOpen && active !== -1 && !multiple) chooseIndex(active);
closeList();
break;
case "Backspace":
if (multiple && !text && selected.length) {
event.preventDefault();
removeValue(selected[selected.length - 1]);
}
break;
}
};
/* ---------------------------------- render --------------------------------- */
const describedBy =
[hint ? hintId : null, hasErrorText ? errorId : null, props["aria-describedby"]].filter(Boolean).join(" ") || undefined;
const statusText = !listOpen
? ""
: loading
? "Loading results."
: loadFailed
? "Results could not be loaded."
: `${flat.length} ${flat.length === 1 ? "result" : "results"}${canCreate ? `, or create ${rawQuery.trim()}` : ""}.`;
const transition = reduceMotion ? { duration: 0 } : { type: "spring" as const, stiffness: 520, damping: 36, mass: 0.7 };
return (
<div
className={`relative w-full ${className}`}
style={{ ["--cb-accent" as string]: accent, ...style }}
{...props}
aria-describedby={undefined}
>
<div className="mb-2 flex items-baseline justify-between gap-3">
<label htmlFor={inputId} className="font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-500 dark:text-zinc-400">
{label}
</label>
{multiple && (
<span aria-hidden="true" className="font-mono text-[10px] tabular-nums tracking-[0.14em] text-zinc-400 dark:text-zinc-500">
{selected.length} selected
</span>
)}
</div>
<div
onMouseDown={(event) => {
// Clicking empty space in the field focuses the input and toggles the list.
if (event.target === event.currentTarget) {
event.preventDefault();
inputRef.current?.focus();
if (listOpen) closeList();
else openList();
}
}}
className={`relative flex min-h-11 w-full cursor-text flex-wrap items-center gap-1.5 rounded-xl border bg-white py-1.5 pl-2 pr-[4.25rem] transition-[border-color,box-shadow] duration-150 dark:bg-zinc-950 motion-reduce:transition-none ${
invalid
? "border-rose-500/80 dark:border-rose-400/80"
: "border-zinc-900/15 hover:border-zinc-900/30 focus-within:border-[var(--cb-accent)] focus-within:shadow-[0_0_0_3px_color-mix(in_srgb,var(--cb-accent)_22%,transparent)] dark:border-white/15 dark:hover:border-white/30"
} ${disabled ? "pointer-events-none opacity-50" : ""}`}
>
{multiple && selected.length > 0 && (
<ul role="list" aria-label="Selected" className="contents">
<AnimatePresence initial={false} mode="popLayout">
{selectedOptions.map((option) => (
<motion.li
key={option.value}
layout={reduceMotion ? false : "position"}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.7 }}
animate={{ opacity: 1, scale: 1 }}
exit={reduceMotion ? { opacity: 0, pointerEvents: "none" } : { opacity: 0, scale: 0.7, pointerEvents: "none" }}
transition={transition}
className="flex"
>
<span className="inline-flex h-7 max-w-[14rem] items-center gap-1 rounded-lg border border-zinc-900/10 bg-zinc-900/[0.04] pl-2.5 pr-1 text-[13px] font-medium text-zinc-900 dark:border-white/10 dark:bg-white/[0.06] dark:text-zinc-100">
<span className="truncate">{option.label}</span>
<button
type="button"
tabIndex={-1}
disabled={disabled}
aria-label={`Remove ${option.label}`}
onMouseDown={(e) => e.preventDefault()}
onClick={() => removeValue(option.value)}
className="grid size-5 shrink-0 place-items-center rounded-md text-zinc-500 transition-colors hover:bg-zinc-900/10 hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/10 dark:hover:text-white"
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
</span>
</motion.li>
))}
</AnimatePresence>
</ul>
)}
<input
ref={inputRef}
id={inputId}
type="text"
role="combobox"
aria-expanded={listOpen}
aria-controls={listboxId}
aria-autocomplete="list"
aria-activedescendant={active !== -1 ? optionId(active) : undefined}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
autoComplete="off"
autoCapitalize="off"
spellCheck={false}
disabled={disabled}
value={inputValue}
placeholder={multiple && selected.length ? "" : placeholder}
onChange={(event) => {
setText(event.target.value);
setDirty(true);
setAnnouncement("");
setOpen(true);
setActiveIndex(-1);
}}
onKeyDown={handleKeyDown}
onClick={() => {
if (!listOpen) openList();
}}
onBlur={() => {
closeList();
if (multiple) setText("");
else settleSingle();
}}
className="h-8 min-w-[6ch] flex-1 bg-transparent px-1.5 text-[14px] text-zinc-950 caret-[var(--cb-accent)] outline-hidden placeholder:text-zinc-400 disabled:cursor-not-allowed dark:text-zinc-50 dark:placeholder:text-zinc-500"
/>
<div className="absolute inset-y-0 right-1.5 flex items-center gap-0.5">
{loading && (
<svg aria-hidden="true" viewBox="0 0 16 16" className="size-4 animate-spin text-[var(--cb-accent)] motion-reduce:animate-none">
<circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" strokeOpacity="0.25" strokeWidth="1.6" />
<path d="M14 8a6 6 0 0 0-6-6" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
)}
{!multiple && selected.length > 0 && !loading && (
<button
type="button"
tabIndex={-1}
aria-label="Clear selection"
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
commit([]);
setText("");
setDirty(false);
announce("Selection cleared.");
inputRef.current?.focus();
}}
className="grid size-7 place-items-center rounded-lg text-zinc-400 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 dark:text-zinc-500 dark:hover:bg-white/[0.08] dark:hover:text-white"
>
<svg aria-hidden="true" viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
<path d="M3 3l6 6M9 3l-6 6" />
</svg>
</button>
)}
<button
type="button"
tabIndex={-1}
aria-label={listOpen ? "Hide options" : "Show options"}
aria-expanded={listOpen}
aria-controls={listboxId}
disabled={disabled}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
inputRef.current?.focus();
if (listOpen) closeList();
else openList();
}}
className="grid size-7 place-items-center rounded-lg text-zinc-500 transition-colors hover:bg-zinc-900/[0.06] hover:text-zinc-900 dark:text-zinc-400 dark:hover:bg-white/[0.08] dark:hover:text-white"
>
<svg
aria-hidden="true"
viewBox="0 0 12 12"
className={`size-3 transition-transform duration-200 motion-reduce:transition-none ${listOpen ? "rotate-180" : ""}`}
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M3 4.5l3 3 3-3" />
</svg>
</button>
</div>
<AnimatePresence>
{listOpen && (
<motion.div
key="popup"
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -6, scaleY: 0.96 }}
animate={{ opacity: 1, y: 0, scaleY: 1 }}
exit={reduceMotion ? { opacity: 0, transition: { duration: 0 } } : { opacity: 0, y: -4, scaleY: 0.98, transition: { duration: 0.12 } }}
transition={reduceMotion ? { duration: 0 } : { type: "spring", stiffness: 600, damping: 40, mass: 0.6 }}
style={{ transformOrigin: "top" }}
className="absolute inset-x-0 top-[calc(100%+6px)] z-30 overflow-hidden rounded-xl border border-zinc-900/10 bg-white shadow-[0_16px_40px_-16px_rgba(0,0,0,0.3)] dark:border-white/10 dark:bg-zinc-900"
onMouseDown={(e) => e.preventDefault()}
>
{/* Thin progress rail while a request is in flight. */}
<div aria-hidden="true" className="relative h-px overflow-hidden bg-zinc-900/[0.06] dark:bg-white/[0.06]">
{loading && (
<div className="absolute inset-y-0 left-0 w-1/3 animate-[cb-rail_1s_ease-in-out_infinite] bg-[var(--cb-accent)] motion-reduce:w-full motion-reduce:animate-none motion-reduce:opacity-50" />
)}
</div>
<div
ref={listRef}
id={listboxId}
role="listbox"
aria-label={typeof label === "string" ? label : "Options"}
aria-multiselectable={multiple || undefined}
aria-busy={loading || undefined}
className={`relative max-h-64 overflow-y-auto overscroll-contain p-1 transition-opacity duration-150 ${loading && flat.length ? "opacity-60" : ""}`}
>
<div
ref={highlightRef}
aria-hidden="true"
style={{ opacity: 0 }}
className="pointer-events-none absolute inset-x-1 top-0 rounded-lg bg-[color-mix(in_srgb,var(--cb-accent)_12%,transparent)] ring-1 ring-inset ring-[color-mix(in_srgb,var(--cb-accent)_35%,transparent)]"
/>
{groups.map((g, gi) => {
const items = g.items.map(({ option, index }) => {
const isSelected = selected.includes(option.value);
return (
<div
key={option.value}
id={optionId(index)}
role="option"
aria-selected={isSelected}
aria-disabled={option.disabled || undefined}
onClick={() => chooseIndex(index)}
onPointerMove={(e) => {
if (e.pointerType === "mouse" && index !== active && !option.disabled) setActiveIndex(index);
}}
className={`relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] ${
option.disabled
? "cursor-not-allowed text-zinc-400 dark:text-zinc-600"
: index === active
? "text-zinc-950 dark:text-white"
: "text-zinc-700 dark:text-zinc-300"
}`}
>
<span
aria-hidden="true"
className={`grid size-4 shrink-0 place-items-center ${multiple ? "rounded border" : ""} ${
multiple
? isSelected
? "border-[var(--cb-accent)] bg-[var(--cb-accent)] text-white dark:text-zinc-950"
: "border-zinc-900/20 dark:border-white/20"
: "text-[var(--cb-accent)]"
}`}
>
{isSelected && (
<svg viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M2.5 6.2l2.3 2.3 4.7-5" />
</svg>
)}
</span>
<span className="min-w-0 flex-1 truncate">
<Highlight text={option.label} query={query} />
</span>
{option.description && (
<span className="shrink-0 font-mono text-[10px] uppercase tracking-[0.12em] text-zinc-400 dark:text-zinc-500">
{option.description}
</span>
)}
</div>
);
});
if (!g.name) return <React.Fragment key={`g-${gi}`}>{items}</React.Fragment>;
const headingId = `${inputId}-group-${gi}`;
return (
<div key={`g-${gi}`} role="group" aria-labelledby={headingId} className={gi > 0 ? "mt-1 border-t border-zinc-900/[0.06] pt-1 dark:border-white/[0.06]" : ""}>
<div
id={headingId}
role="presentation"
className="px-2.5 pb-1 pt-1.5 font-mono text-[10px] uppercase tracking-[0.14em] text-zinc-400 dark:text-zinc-500"
>
{g.name}
</div>
{items}
</div>
);
})}
{canCreate && (
<div
id={optionId(createIndex)}
role="option"
aria-selected={false}
onClick={() => chooseIndex(createIndex)}
onPointerMove={(e) => {
if (e.pointerType === "mouse" && createIndex !== active) setActiveIndex(createIndex);
}}
className={`relative flex cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] ${
flat.length ? "mt-1" : ""
} ${createIndex === active ? "text-zinc-950 dark:text-white" : "text-zinc-700 dark:text-zinc-300"}`}
>
<span aria-hidden="true" className="grid size-4 shrink-0 place-items-center text-[var(--cb-accent)]">
<svg viewBox="0 0 12 12" className="size-3" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<path d="M6 2.5v7M2.5 6h7" />
</svg>
</span>
<span className="min-w-0 flex-1 truncate">
Create <span className="font-semibold">“{rawQuery.trim()}”</span>
</span>
</div>
)}
</div>
{count === 0 && (
<p className="px-3.5 pb-3.5 pt-2.5 text-[13px] text-zinc-500 dark:text-zinc-400">
{loading ? "Searching…" : loadFailed ? "Couldn’t load results. Keep typing to retry." : emptyMessage}
</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
{hint && (
<p id={hintId} className="mt-1.5 text-[13px] leading-snug text-zinc-500 dark:text-zinc-400">
{hint}
</p>
)}
<div aria-live="polite">
{hasErrorText && (
<p id={errorId} className="pt-1.5 text-[13px] font-medium leading-snug text-rose-600 dark:text-rose-400">
{error}
</p>
)}
</div>
{/* Result counts and selection changes, announced politely. */}
<span className="sr-only" aria-live="polite" aria-atomic="true">
{announcement || statusText}
</span>
{name && selected.map((v) => <input key={v} type="hidden" name={name} value={v} />)}
<style>{`@keyframes cb-rail{0%{transform:translateX(-100%)}100%{transform:translateX(300%)}}`}</style>
</div>
);
}