import { useId, useMemo, useRef, useState } from 'react'; import { ChevronDown } from 'lucide-react'; export interface PractitionerComboOption { key: string; name: string; /** Role and specialty, when the record carries them. */ detail?: string; /** The signed-in viewer. */ isYou?: boolean; } interface PractitionerComboboxProps { label: string; allLabel: string; options: PractitionerComboOption[]; /** `undefined` is the all-practitioners state. */ selectedKey: string | undefined; onSelect: (key: string | undefined) => void; } const termsOf = (query: string) => query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean); /** The "(you)" mark: a tag to the eye, the words in parentheses to a screen reader. */ const YouTag = ({ id }: { id?: string }) => ( (you) ); export const matchSentence = (matched: number, total: number, query: string) => `${matched} of ${total} ${total === 1 ? 'practitioner' : 'practitioners'} ${matched === 1 ? 'matches' : 'match'} “${query.trim()}”`; export const noMatchSentence = (query: string) => `No practitioner matches “${query.trim()}”.`; export const clearTheBoxSentence = (total: number) => `Clear the box to see ${total === 1 ? 'the one practitioner' : `all ${total}`}.`; /** A type-ahead combobox: holds the current value, filters as you type, Escape restores. */ const PractitionerCombobox = ({ label, allLabel, options, selectedKey, onSelect }: PractitionerComboboxProps) => { const baseId = useId(); const inputId = `${baseId}-input`; const listId = `${baseId}-list`; const youId = `${baseId}-you`; const inputRef = useRef(null); const [open, setOpen] = useState(false); // null while not typing, so the field shows what it holds. const [query, setQuery] = useState(null); const [activeIndex, setActiveIndex] = useState(-1); const selected = selectedKey ? options.find((option) => option.key === selectedKey) : undefined; const typing = query !== null && query.trim() !== ''; const shown = useMemo<(PractitionerComboOption | null)[]>(() => { if (!typing) return [null, ...options]; const terms = termsOf(query ?? ''); return options.filter((option) => { const name = option.name.toLocaleLowerCase(); return terms.every((term) => name.includes(term)); }); }, [options, query, typing]); const close = () => { setOpen(false); setQuery(null); setActiveIndex(-1); }; const choose = (option: PractitionerComboOption | null) => { onSelect(option?.key); close(); }; const optionId = (index: number) => `${baseId}-opt-${index}`; return (
{ if (!event.currentTarget.contains(event.relatedTarget as Node | null)) close(); }} > {/* The box only hands a pointer press to the input, which keyboard users reach directly. */}
{ if (event.target !== inputRef.current) { event.preventDefault(); inputRef.current?.focus(); } }} > = 0 ? optionId(activeIndex) : undefined} autoComplete="off" spellCheck={false} value={query ?? (selected ? selected.name : allLabel)} onFocus={(event) => { setOpen(true); event.currentTarget.select(); }} onChange={(event) => { setQuery(event.target.value); setOpen(true); setActiveIndex(event.target.value.trim() ? 0 : -1); }} onKeyDown={(event) => { if (event.key === 'Escape') { event.preventDefault(); close(); return; } if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault(); setOpen(true); if (shown.length > 0) { const step = event.key === 'ArrowDown' ? 1 : -1; setActiveIndex((current) => (current + step + shown.length) % shown.length); } return; } if (event.key === 'Enter' && open && activeIndex >= 0 && activeIndex < shown.length) { event.preventDefault(); choose(shown[activeIndex]); } }} /> {query === null && selected?.isYou && }
{open && (
    {shown.map((option, index) => (
  • { event.preventDefault(); choose(option); }} onMouseEnter={() => setActiveIndex(index)} > {option ? option.name : allLabel} {option?.isYou && <> } {option?.detail && ( {option.detail} )}
  • ))}
{typing && (shown.length > 0 ? (

{matchSentence(shown.length, options.length, query ?? '')}

) : (

{noMatchSentence(query ?? '')} {clearTheBoxSentence(options.length)}

))}
)}
); }; export default PractitionerCombobox;