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 ;
}