import type { JSX } from 'solid-js'; import { createEffect, mergeProps, untrack } from 'solid-js'; import { omitProps } from 'solid-use/props'; import { useAutocompleteState } from '../../states/create-autocomplete-state'; import { useDisclosureState } from '../../states/create-disclosure-state'; import createDynamic from '../../utils/create-dynamic'; import type { DynamicProps, HeadlessPropsWithRef, ValidConstructor, } from '../../utils/dynamic-prop'; import { createForwardRef } from '../../utils/dynamic-prop'; import { createARIADisabledState, createARIAExpandedState, createDisabledState, createExpandedState, createHasActiveState, createHasQueryState, createHasSelectedState, } from '../../utils/state-props'; import useEventListener from '../../utils/use-event-listener'; import { COMMAND_INPUT_TAG } from '../command/tags'; import { useComboboxContext } from './ComboboxContext'; export type ComboboxInputProps = HeadlessPropsWithRef; /** * The text field of a `Combobox`. Typing sets the query, which is debounced by * 250ms, and the arrow keys move the active option through `aria- * activedescendant` while focus stays in the input. * * Renders an `` by default. * * @see {@link https://github.com/lxsmnsyc/terracotta/blob/main/docs/components/combobox.md} */ export function ComboboxInput( props: ComboboxInputProps, ): JSX.Element { const context = useComboboxContext('ComboboxInput'); const autocompleteState = useAutocompleteState(); const disclosureState = useDisclosureState(); const [internalRef, setInternalRef] = createForwardRef(props); const isDisabled = (): boolean | undefined => autocompleteState.disabled() || props.disabled; createEffect(() => { const current = internalRef(); if (current instanceof HTMLElement) { context.anchor = current; if (current instanceof HTMLInputElement) { useEventListener(current, 'input', () => { if (!isDisabled()) { autocompleteState.setQuery(current.value); } }); } useEventListener(current, 'keydown', (e) => { if (!isDisabled()) { // Keys this panel acts on are not passed on: a `Dialog` or another panel // around this one traps `Tab` and closes on `Escape` too, and would // otherwise move focus a second time or close both layers at once. switch (e.key) { case 'Escape': { e.stopPropagation(); disclosureState.close(); break; } case 'ArrowUp': { e.preventDefault(); if (disclosureState.isOpen()) { context.controller.setPrevChecked(true); } else { disclosureState.open(); } break; } case 'ArrowDown': { e.preventDefault(); if (disclosureState.isOpen()) { context.controller.setNextChecked(true); } else { disclosureState.open(); } break; } case 'Enter': { e.preventDefault(); if (disclosureState.isOpen()) { context.selectedDescendant = context.activeDescendant; } break; } default: break; } } }); useEventListener(current, 'click', () => { if (!isDisabled()) { disclosureState.toggle(); } }); useEventListener(current, 'blur', (e) => { if (context.optionsHovering) { return; } autocompleteState.blur(); if ( (e.relatedTarget && !current.contains(e.relatedTarget as Node)) || (e.target && !current.contains(e.target as Node)) ) { disclosureState.close(); } }); useEventListener(current, 'mouseenter', () => { context.inputHovering = true; }); useEventListener(current, 'mouseleave', () => { context.inputHovering = false; }); } }); createEffect(() => { if (autocompleteState.query() !== '') { if (untrack(() => disclosureState.isOpen())) { context.controller.setFirstChecked(); } else { disclosureState.open(); } } }); createEffect(() => { if (context.activeDescendant) { const current = document.getElementById(context.activeDescendant); if (current) { context.controller.setCurrent(current); } } }); return createDynamic( () => props.as ?? ('input' as T), mergeProps( COMMAND_INPUT_TAG, { id: context.inputID, ref: setInternalRef, // Guarantee it's a text type: 'text', // Guarantee it's interactive tabindex: 0, role: 'combobox', // Controls the options listbox 'aria-haspopup': 'listbox', 'aria-controls': context.optionsID, 'aria-labelledby': context.labelID, get 'aria-expanded'() { return disclosureState.isOpen(); }, get 'aria-activedescendant'() { return context.activeDescendant; }, }, createDisabledState(isDisabled), createARIADisabledState(isDisabled), createExpandedState(() => disclosureState.isOpen()), createARIAExpandedState(() => disclosureState.isOpen()), createHasSelectedState(() => autocompleteState.hasSelected()), createHasActiveState(() => autocompleteState.hasActive()), createHasQueryState(() => autocompleteState.hasQuery()), omitProps(props, ['as', 'ref']), ) as DynamicProps, ); }