import type { JSX, ValidComponent } from "@solidjs/web"; import { dynamic } from "@solidjs/web"; import { createMemo, omit } from "solid-js"; import { createChangeEventDetails, REASONS, } from "../../internals/change-event-details"; import { composeRefs, userRef } from "../../internals/compose-refs"; import { mergeRootProps } from "../../internals/merge-root-props"; import { resolveClassStyle } from "../../internals/resolve-class-style"; import type { BaseUIComponentProps } from "../../internals/types"; import { useComboboxChipsContext } from "../chips/chips-context"; import type { ComboboxSide } from "../positioner/positioner-context"; import { type ComboboxRootChangeEventDetails, useComboboxRootContext, } from "../root/combobox-root-context"; export interface ComboboxInputState { open: boolean; disabled: boolean; readOnly: boolean; popupSide: ComboboxSide | null; listEmpty: boolean; } export interface ComboboxInputProps extends BaseUIComponentProps<"input", ComboboxInputState> { as?: ValidComponent; disabled?: boolean | undefined; } /** * A text input to search for items in the list. Renders an ``. */ export function ComboboxInput(props: ComboboxInputProps): JSX.Element { const root = useComboboxRootContext(); const chips = useComboboxChipsContext(); const disabled = (): boolean => root.disabled() || (props.disabled ?? false); const readOnly = (): boolean => root.readOnly(); const listEmpty = createMemo( () => root.flatFilteredItems().length === 0, ); const setInputRef = (el: HTMLInputElement | null): void => { root.setInputElement(el); }; const state = createMemo(() => ({ open: root.open(), disabled: disabled(), readOnly: readOnly(), popupSide: root.popupSide(), listEmpty: listEmpty(), })); function onInput(event: InputEvent): void { const value = (event.currentTarget as HTMLInputElement).value; const details = createChangeEventDetails( REASONS.inputChange, event, ) as ComboboxRootChangeEventDetails; root.setInputValue(value, details); if (!readOnly() && !disabled()) { if (!root.open() && (value !== "" || root.openOnInputClick())) { root.setOpen(true, details); } } if (!root.autoHighlightMode()) { root.setIndices({ activeIndex: null, type: "keyboard" }); } } // Chip keyboard navigation (multi-select only). Returns `true` when the // event was consumed so the main handler can bail before the // Enter/Escape logic runs. No-op (returns `false`) when chips aren't in use. function handleChipKeyDown(event: KeyboardEvent): boolean { if (!chips) return false; const inputEl = event.currentTarget as HTMLInputElement | null; const sv = root.selectedValue(); const selectedArr = Array.isArray(sv) ? (sv as unknown[]) : []; const highlighted = chips.highlightedChipIndex(); const renderedChips = chips.chipsRef.current.length; // A chip is already focused: arrow keys move between chips, stepping // back into the input past either end. if (highlighted !== undefined) { if (event.key === "ArrowLeft") { event.preventDefault(); const next = highlighted > 0 ? highlighted - 1 : undefined; chips.setHighlightedChipIndex(next); if (next !== undefined) chips.chipsRef.current[next]?.focus(); else root.inputRef.current?.focus(); return true; } if (event.key === "ArrowRight") { event.preventDefault(); const next = highlighted < renderedChips - 1 ? highlighted + 1 : undefined; chips.setHighlightedChipIndex(next); if (next !== undefined) chips.chipsRef.current[next]?.focus(); else root.inputRef.current?.focus(); return true; } return false; } // Caret at the input's start: ArrowLeft enters the chip strip. if ( event.key === "ArrowLeft" && inputEl && (inputEl.selectionStart ?? 0) === 0 && selectedArr.length > 0 ) { event.preventDefault(); const next = renderedChips > 0 ? renderedChips - 1 : undefined; chips.setHighlightedChipIndex(next); if (next !== undefined) chips.chipsRef.current[next]?.focus(); return true; } // Empty input: Backspace removes the last selected chip. if ( event.key === "Backspace" && inputEl && inputEl.value === "" && selectedArr.length > 0 ) { event.preventDefault(); const removalIndex = renderedChips > 0 ? renderedChips - 1 : selectedArr.length - 1; root.setIndices({ activeIndex: null, selectedIndex: null, type: root.keyboardActiveRef.current ? "keyboard" : "pointer", }); const details = createChangeEventDetails( REASONS.none, event, ) as ComboboxRootChangeEventDetails; root.setSelectedValue( selectedArr.filter((_, i) => i !== removalIndex) as never, details, ); return true; } return false; } function onKeyDown(event: KeyboardEvent): void { if (disabled() || readOnly()) return; root.keyboardActiveRef.current = true; if (handleChipKeyDown(event)) return; if (event.key === "Enter" && root.open()) { const ai = root.activeIndex(); if (ai == null) return; event.preventDefault(); const li = root.listRef.current[ai]; if (li) { li.click(); } return; } if ( event.key === "Escape" && !root.open() && root.selectedValue() != null && !Array.isArray(root.selectedValue()) ) { const details = createChangeEventDetails( REASONS.escapeKey, event, ) as ComboboxRootChangeEventDetails; root.setInputValue("", details); root.setSelectedValue(null, details); } } function onPointerMove(): void { root.keyboardActiveRef.current = false; } function onPointerDown(): void { root.keyboardActiveRef.current = false; } const internal = { type: "text", autocomplete: "off" as const, spellcheck: false, role: "combobox", "aria-autocomplete": "list" as const, get "aria-expanded"() { return root.open() ? ("true" as const) : ("false" as const); }, get "aria-haspopup"() { return root.grid() ? ("grid" as const) : ("listbox" as const); }, get "aria-controls"() { return root.open() ? (root.listElement()?.id ?? "") : ""; }, get value() { return root.inputValue(); }, get disabled() { return disabled(); }, get readOnly() { return readOnly(); }, get required() { return root.required(); }, onInput, onKeyDown, onPointerMove, onPointerDown, // inputStateAttributesMapping projection. get "data-popup-open"() { return state().open ? "" : undefined; }, get "data-disabled"() { return state().disabled ? "" : undefined; }, get "data-readonly"() { return state().readOnly ? "" : undefined; }, get "data-list-empty"() { return state().listEmpty ? "" : undefined; }, get "data-popup-side"() { return state().popupSide ?? undefined; }, }; const userClassStyle = resolveClassStyle(props, () => state()); const userProps = omit(props, "as", "class", "style", "attrs", "disabled"); let merged: Record = mergeRootProps( root.interactions.getReferenceProps(), internal, ); merged = mergeRootProps(merged, userClassStyle); merged = mergeRootProps(merged, userProps); const Tag = dynamic(() => props.as ?? "input"); return ; }