learning_ai_common_plat/packages/ui/src/components/Combobox.tsx
saravanakumardb1 e5061350a5 feat(ui): Combobox scroll-into-view, opt-in TagInput blur-commit, unit tests (V4 IMP-1/3, GAP-4)
@bytelyst/ui 0.2.0 -> 0.2.1

- Combobox: scroll the highlighted option into view during arrow-key nav so
  it never leaves the popover viewport (IMP-1)
- TagInput: new commitOnBlur prop (default false) — committing the buffer on
  blur surprised users clicking away to discard. BEHAVIOR CHANGE: blur no
  longer commits unless commitOnBlur is set (IMP-3)
- Add vitest + happy-dom + @testing-library/react devDeps, test script, and
  packages/ui/vitest.config.ts; 6 unit tests for Combobox + TagInput (GAP-4)
- Drop the stale 'vitest pending' ROADMAP-EXEC-TODO comments

6/6 tests pass; tsc clean.
2026-05-28 15:57:49 -07:00

260 lines
8.3 KiB
TypeScript

import * as React from 'react';
import { clsx } from 'clsx';
import { Check, ChevronDown, X } from 'lucide-react';
export interface ComboboxOption<T extends string = string> {
value: T;
label: string;
/** Optional secondary line. */
description?: string;
/** Disables selection but keeps the row visible. */
disabled?: boolean;
}
export interface ComboboxProps<T extends string = string> {
/** Available options. */
options: ComboboxOption<T>[];
/** Controlled value. `null` represents no selection. */
value: T | null;
/** Called when the user picks (or clears) a value. */
onChange: (next: T | null) => void;
/** Placeholder shown when nothing is selected. */
placeholder?: string;
/** Custom filter — defaults to case-insensitive label/value substring. */
filter?: (option: ComboboxOption<T>, query: string) => boolean;
/** Allow clearing the selection with an `x`. Default true. */
clearable?: boolean;
/** Disable the entire control. */
disabled?: boolean;
/** Accessible label. */
ariaLabel?: string;
/** Max popover height in px. Default 240. */
maxListHeight?: number;
className?: string;
}
const DEFAULT_FILTER = <T extends string>(opt: ComboboxOption<T>, q: string) => {
if (!q) return true;
const needle = q.toLowerCase();
return (
opt.label.toLowerCase().includes(needle) ||
opt.value.toLowerCase().includes(needle)
);
};
/**
* `<Combobox>` — searchable select. Headless-ish: standard semantics
* (`role="combobox"` + `aria-expanded` + roving `aria-activedescendant`)
* with token-driven styling.
*
* Keyboard:
* - **↓ / ↑** move highlight
* - **Enter** pick the highlighted option
* - **Esc** close
* - **Tab** close + commit text query as no-op
*
* Closes the v3.0 audit gap where async-loading selects in
* notes/fastgap/voice were each bespoke.
*/
export function Combobox<T extends string = string>({
options,
value,
onChange,
placeholder = 'Select…',
filter = DEFAULT_FILTER,
clearable = true,
disabled = false,
ariaLabel,
maxListHeight = 240,
className,
}: ComboboxProps<T>) {
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState('');
const [highlight, setHighlight] = React.useState(0);
const rootRef = React.useRef<HTMLDivElement>(null);
const listId = React.useId();
const filtered = React.useMemo(
() => options.filter((opt) => filter(opt, query)),
[options, query, filter],
);
// Reset highlight when the visible list changes shape.
React.useEffect(() => {
setHighlight(0);
}, [query, open]);
// Keep the highlighted option visible during arrow-key navigation so it
// never scrolls out of the popover viewport (ROADMAP V4 IMP-1).
React.useEffect(() => {
if (!open) return;
const el = document.getElementById(`${listId}-opt-${highlight}`);
el?.scrollIntoView({ block: 'nearest' });
}, [highlight, open, listId]);
// Click-outside close.
React.useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDoc);
return () => document.removeEventListener('mousedown', onDoc);
}, [open]);
const selectedOption = options.find((o) => o.value === value) ?? null;
const displayText = open ? query : selectedOption?.label ?? '';
const commit = (opt: ComboboxOption<T>) => {
if (opt.disabled) return;
onChange(opt.value);
setQuery('');
setOpen(false);
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (disabled) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
if (!open) setOpen(true);
setHighlight((h) => Math.min(filtered.length - 1, h + 1));
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
setHighlight((h) => Math.max(0, h - 1));
return;
}
if (e.key === 'Enter') {
if (!open) {
setOpen(true);
return;
}
e.preventDefault();
const opt = filtered[highlight];
if (opt) commit(opt);
return;
}
if (e.key === 'Escape') {
setOpen(false);
setQuery('');
return;
}
};
return (
<div
ref={rootRef}
data-testid="bl-combobox"
className={clsx('relative', className)}
>
<div
className={clsx(
'flex items-center gap-1 rounded-lg border bg-[var(--bl-surface-card,#fff)] px-2 transition',
'border-[var(--bl-border,rgba(0,0,0,0.12))]',
'focus-within:border-[var(--bl-accent,#6366f1)] focus-within:ring-2 focus-within:ring-[var(--bl-accent-muted,rgba(99,102,241,0.18))]',
disabled && 'cursor-not-allowed opacity-60',
)}
>
<input
role="combobox"
aria-expanded={open}
aria-controls={listId}
aria-autocomplete="list"
aria-activedescendant={
open && filtered[highlight] ? `${listId}-opt-${highlight}` : undefined
}
aria-label={ariaLabel ?? placeholder}
type="text"
value={displayText}
onChange={(e) => {
setQuery(e.target.value);
if (!open) setOpen(true);
}}
onFocus={() => !disabled && setOpen(true)}
onKeyDown={onKeyDown}
placeholder={placeholder}
disabled={disabled}
className="h-9 flex-1 bg-transparent px-1 text-sm outline-none placeholder:text-[var(--bl-text-tertiary,#999)]"
/>
{clearable && value !== null && !disabled && (
<button
type="button"
aria-label="Clear selection"
onClick={() => {
onChange(null);
setQuery('');
}}
className="grid place-items-center rounded p-1 text-[var(--bl-text-tertiary,#999)] hover:bg-[var(--bl-surface-muted,rgba(0,0,0,0.05))]"
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</button>
)}
<ChevronDown
className={clsx(
'h-4 w-4 text-[var(--bl-text-tertiary,#999)] transition-transform',
open && 'rotate-180',
)}
aria-hidden="true"
/>
</div>
{open && (
<ul
id={listId}
role="listbox"
data-testid="bl-combobox-list"
style={{ maxHeight: maxListHeight }}
className="absolute left-0 right-0 top-[calc(100%+4px)] z-50 overflow-auto rounded-lg border border-[var(--bl-border,rgba(0,0,0,0.12))] bg-[var(--bl-surface-card,#fff)] shadow-lg"
>
{filtered.length === 0 ? (
<li className="px-3 py-2 text-sm text-[var(--bl-text-tertiary,#999)]">
No matches
</li>
) : (
filtered.map((opt, i) => {
const active = i === highlight;
const selected = opt.value === value;
return (
<li
key={opt.value}
id={`${listId}-opt-${i}`}
role="option"
aria-selected={selected}
aria-disabled={opt.disabled}
onMouseEnter={() => setHighlight(i)}
onMouseDown={(e) => {
e.preventDefault(); // keep input focused
commit(opt);
}}
className={clsx(
'flex cursor-pointer items-start justify-between gap-2 px-3 py-2 text-sm',
active && 'bg-[var(--bl-surface-muted,rgba(0,0,0,0.05))]',
opt.disabled && 'cursor-not-allowed opacity-50',
)}
>
<div className="flex flex-col">
<span>{opt.label}</span>
{opt.description && (
<span className="text-xs text-[var(--bl-text-tertiary,#999)]">
{opt.description}
</span>
)}
</div>
{selected && (
<Check
className="mt-0.5 h-4 w-4 shrink-0 text-[var(--bl-accent,#6366f1)]"
aria-hidden="true"
/>
)}
</li>
);
})
)}
</ul>
)}
</div>
);
}
Combobox.displayName = 'Combobox';