| 278 | const FREEFORM_COMBOBOX_LIMIT = 100; |
| 279 | |
| 280 | function FreeformCombobox(props: { |
| 281 | readonly value: string; |
| 282 | readonly onValueChange: (value: string) => void; |
| 283 | readonly options: readonly FreeformComboboxOption[]; |
| 284 | readonly placeholder?: string; |
| 285 | readonly emptyLabel?: React.ReactNode; |
| 286 | readonly className?: string; |
| 287 | readonly inputClassName?: string; |
| 288 | readonly disabled?: boolean; |
| 289 | /** Forwarded to the underlying text input so a `<Label htmlFor>` can target it |
| 290 | * (and tests get a stable selector). */ |
| 291 | readonly id?: string; |
| 292 | }) { |
| 293 | const valueOption = props.value.trim(); |
| 294 | const propsOptions = props.options; |
| 295 | const options = React.useMemo<readonly FreeformComboboxOption[]>( |
| 296 | () => |
| 297 | valueOption.length > 0 && !propsOptions.some((option) => option.value === valueOption) |
| 298 | ? [{ value: valueOption, label: valueOption }, ...propsOptions] |
| 299 | : propsOptions, |
| 300 | [valueOption, propsOptions], |
| 301 | ); |
| 302 | const selectedValue = options.some((option) => option.value === props.value) ? props.value : null; |
| 303 | |
| 304 | const byValue = React.useMemo(() => { |
| 305 | const map = new Map<string, FreeformComboboxOption>(); |
| 306 | for (const option of options) map.set(option.value, option); |
| 307 | return map; |
| 308 | }, [options]); |
| 309 | const items = React.useMemo(() => options.map((option) => option.value), [options]); |
| 310 | |
| 311 | // base-ui only filters the popup when the list renders from `items` (the |
| 312 | // function-child / closed-template path below); a statically-mapped list never |
| 313 | // narrows as you type. Match the query against the value AND its string |
| 314 | // label/description, so typing a summary word or the human label finds the |
| 315 | // option, not just its raw id. |
| 316 | const filter = React.useCallback( |
| 317 | (item: string, query: string) => { |
| 318 | const needle = query.trim().toLowerCase(); |
| 319 | if (needle === "") return true; |
| 320 | const option = byValue.get(item); |
| 321 | const haystacks = [ |
| 322 | item, |
| 323 | typeof option?.label === "string" ? option.label : "", |
| 324 | typeof option?.description === "string" ? option.description : "", |
| 325 | ]; |
| 326 | return haystacks.some((part) => part.toLowerCase().includes(needle)); |
| 327 | }, |
| 328 | [byValue], |
| 329 | ); |
| 330 | |
| 331 | return ( |
| 332 | <Combobox |
| 333 | items={items} |
| 334 | inputValue={props.value} |
| 335 | value={selectedValue} |
| 336 | filter={filter} |
| 337 | limit={FREEFORM_COMBOBOX_LIMIT} |