@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.
260 lines
8.3 KiB
TypeScript
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';
|