"use client" import * as React from "react" import { ChevronDown, Loader2, X } from "lucide-react" import { cn } from "../../lib/utils" import { Z_POPOVER_CLASS } from "../../lib/stackingLayers" import { Input } from "./input" /** * Replaces `@wordpress/components`' `ComboboxControl` (5 sites) and the `ComboboxControlOption` * type imported from `@wordpress/components/build-types/combobox-control/types`. * * An adapter, not a rename. The payload trap is the opposite way round from `CustomSelectControl`: * WP hands this `onChange` the option's VALUE STRING, not an object and not an event. Sites do * `options.find(o => o.code === value)` on it, so handing over the option would silently find * nothing. * * `onFilterValueChange` fires on every keystroke and is how PatientSearch and UcumUnitSearch drive * a server search, so it must fire before any local filtering. * * Hand-rolled rather than built on Radix: WP's list is in flow and absolutely positioned under the * field, not portalled, and matching that avoids a portal and a stacking layer this control never * needed. * * `label` and `help` are rendered here, as WP did. `__next40pxDefaultSize` and * `__nextHasNoMarginBottom` are not accepted; call sites drop them. */ export type ComboboxControlOption = { label: string value: string disabled?: boolean // `any`, not `unknown`, copied from WP's own type. Same reason as ui/custom-select-control. // eslint-disable-next-line @typescript-eslint/no-explicit-any [key: string]: any } export interface ComboboxControlProps { label?: React.ReactNode help?: React.ReactNode /** Keep the label for screen readers only. */ hideLabelFromVision?: boolean options: ComboboxControlOption[] value?: string | null /** WP's contract: the option's VALUE, not the option and not an event. */ onChange?: (value: string | null | undefined) => void /** Every keystroke. Two sites use it to run a server search. */ onFilterValueChange?: (value: string) => void /** Not a WP prop. PatientSearch already passes it, where WP dropped it on the floor. */ onReset?: () => void allowReset?: boolean expandOnFocus?: boolean isLoading?: boolean /** What an empty list says. `null` says nothing: a search that was refused is not an empty one. */ emptyMessage?: string | null /** Filter the options by what was typed. Off when the options are already a server's answer to that text: a * phone or an alias it matched need not appear in the label. */ filterOptions?: boolean placeholder?: string disabled?: boolean className?: string id?: string } /** Case and accent insensitive, matching WP's own filter. */ function fold(text: string) { return text .normalize("NFD") .replace(/\p{Diacritic}/gu, "") .toLowerCase() } function ComboboxControl({ label, help, hideLabelFromVision = false, options, value, onChange, onFilterValueChange, onReset, allowReset = true, expandOnFocus = true, isLoading = false, emptyMessage = "No results found.", filterOptions = true, placeholder, disabled, className, id, }: ComboboxControlProps) { const generated = React.useId().replace(/:/g, "") const inputId = id ?? `combobox-control-${generated}` const labelId = `${inputId}-label` const listId = `${inputId}-list` const helpId = help ? `${inputId}-help` : undefined const [isOpen, setIsOpen] = React.useState(false) const [activeIndex, setActiveIndex] = React.useState(-1) // `null` means "has not typed", so the field mirrors the selection. Any string is a live filter. // This is what lets focus open the full list while the selected label is still on screen. const [typed, setTyped] = React.useState(null) const selectedOption = React.useMemo( () => (value == null ? undefined : options.find((option) => option.value === value)), [options, value] ) const visibleOptions = React.useMemo(() => { if (!filterOptions || typed === null || typed === "") return options const needle = fold(typed) return options.filter((option) => fold(option.label).includes(needle)) }, [options, typed, filterOptions]) const inputValue = typed ?? selectedOption?.label ?? "" const open = () => { if (disabled) return setIsOpen(true) } const close = () => { setIsOpen(false) setActiveIndex(-1) setTyped(null) } const select = (option: ComboboxControlOption) => { if (option.disabled) return onChange?.(option.value) close() } // Leaving a fixed list with text that names one option takes it, so "Chicago" is not silently dropped. // A search box (onFilterValueChange) is left alone: its options are results, not a closed list. const namedByTyped = () => { if (!typed || onFilterValueChange) return undefined const needle = fold(typed.trim()) const exact = options.find((option) => !option.disabled && fold(option.label) === needle) const matches = visibleOptions.filter((option) => !option.disabled) return exact ?? (matches.length === 1 ? matches[0] : undefined) } const leave = () => { const pick = namedByTyped() if (pick && pick.value !== value) onChange?.(pick.value) close() } const reset = () => { onChange?.(null) onReset?.() onFilterValueChange?.("") close() } const handleInputChange = (event: React.ChangeEvent) => { const next = event.target.value setTyped(next) setActiveIndex(-1) setIsOpen(true) onFilterValueChange?.(next) } const move = (delta: number) => { if (visibleOptions.length === 0) return setActiveIndex((current) => { const next = current + delta if (next < 0) return visibleOptions.length - 1 if (next >= visibleOptions.length) return 0 return next }) } const handleKeyDown = (event: React.KeyboardEvent) => { if (event.key === "ArrowDown") { event.preventDefault() if (!isOpen) open() else move(1) return } if (event.key === "ArrowUp") { event.preventDefault() if (!isOpen) open() else move(-1) return } if (event.key === "Home" && isOpen) { event.preventDefault() setActiveIndex(0) return } if (event.key === "End" && isOpen) { event.preventDefault() setActiveIndex(visibleOptions.length - 1) return } if (event.key === "Enter" && isOpen && activeIndex >= 0) { event.preventDefault() select(visibleOptions[activeIndex]) return } // Typed text in a fixed list is a choice still being made: a form submit here would save the old selection. if (event.key === "Enter" && typed && !onFilterValueChange) { event.preventDefault() const pick = namedByTyped() if (pick) select(pick) else open() return } if (event.key === "Escape" && isOpen) { event.preventDefault() close() } } // A search list with nothing typed has not been searched, so it opens only once there is something to say. const showList = isOpen && !disabled && (visibleOptions.length > 0 || Boolean(typed) || Boolean(isLoading)) const activeOptionId = activeIndex >= 0 ? `${listId}-${activeIndex}` : undefined return (
{label && ( )} {/* `relative` is load bearing: the list is absolutely positioned against this box. */}
{ if (expandOnFocus) open() }} onClick={open} onBlur={leave} onKeyDown={handleKeyDown} /> {/* The strip sits over the input's right edge, so it must not swallow clicks: without `pointer-events-none` here, clicking the chevron does nothing at all. The reset button takes its own events back. */}
{isLoading && } {allowReset && !isLoading && (selectedOption || inputValue) && ( )}
{showList && (
    {visibleOptions.length === 0 && (isLoading || emptyMessage !== null) && ( // `presentation`, so the only children a listbox reports are real options.
  • {isLoading ? "Loading…" : emptyMessage}
  • )} {visibleOptions.map((option, index) => (
  • { event.preventDefault() select(option) }} onMouseEnter={() => setActiveIndex(index)} className={cn( "cursor-pointer px-3 py-2 text-sm text-ink", index === activeIndex && "bg-accent", option.value === value && "font-medium", option.disabled && "pointer-events-none opacity-50" )} > {option.label}
  • ))}
)}
{help && (

{help}

)}
) } export { ComboboxControl }