/** * USWDS Combo Box Behavior * * Mirrors official USWDS combo box JavaScript behavior exactly * * @uswds-source https://github.com/uswds/uswds/blob/develop/packages/usa-combo-box/src/index.js * @uswds-version 3.8.0 * @last-synced 2025-10-05 * @sync-status ✅ UP TO DATE * * CRITICAL: This file replicates USWDS source code to maintain 100% behavior parity. * DO NOT add custom logic. ALL changes must come from USWDS source updates. */ import { selectOrMatches } from '../../utils/select-or-matches.js'; import { Sanitizer } from '../../utils/sanitizer.js'; /** * Constants from USWDS * * SOURCE: index.js (Lines 8-36) */ const PREFIX = 'usa'; const COMBO_BOX_CLASS = `${PREFIX}-combo-box`; const COMBO_BOX_PRISTINE_CLASS = `${COMBO_BOX_CLASS}--pristine`; const SELECT_CLASS = `${COMBO_BOX_CLASS}__select`; const INPUT_CLASS = `${COMBO_BOX_CLASS}__input`; const CLEAR_INPUT_BUTTON_CLASS = `${COMBO_BOX_CLASS}__clear-input`; const CLEAR_INPUT_BUTTON_WRAPPER_CLASS = `${CLEAR_INPUT_BUTTON_CLASS}__wrapper`; const INPUT_BUTTON_SEPARATOR_CLASS = `${COMBO_BOX_CLASS}__input-button-separator`; const TOGGLE_LIST_BUTTON_CLASS = `${COMBO_BOX_CLASS}__toggle-list`; const TOGGLE_LIST_BUTTON_WRAPPER_CLASS = `${TOGGLE_LIST_BUTTON_CLASS}__wrapper`; const LIST_CLASS = `${COMBO_BOX_CLASS}__list`; const LIST_OPTION_CLASS = `${COMBO_BOX_CLASS}__list-option`; const LIST_OPTION_FOCUSED_CLASS = `${LIST_OPTION_CLASS}--focused`; const LIST_OPTION_SELECTED_CLASS = `${LIST_OPTION_CLASS}--selected`; const STATUS_CLASS = `${COMBO_BOX_CLASS}__status`; const COMBO_BOX = `.${COMBO_BOX_CLASS}`; const SELECT = `.${SELECT_CLASS}`; const INPUT = `.${INPUT_CLASS}`; const CLEAR_INPUT_BUTTON = `.${CLEAR_INPUT_BUTTON_CLASS}`; const TOGGLE_LIST_BUTTON = `.${TOGGLE_LIST_BUTTON_CLASS}`; const LIST = `.${LIST_CLASS}`; const LIST_OPTION = `.${LIST_OPTION_CLASS}`; const LIST_OPTION_FOCUSED = `.${LIST_OPTION_FOCUSED_CLASS}`; const LIST_OPTION_SELECTED = `.${LIST_OPTION_SELECTED_CLASS}`; const STATUS = `.${STATUS_CLASS}`; const DEFAULT_FILTER = '.*{{query}}.*'; const noop = () => {}; /** * Combo box context */ interface ComboBoxContext { comboBoxEl: HTMLElement; selectEl: HTMLSelectElement; inputEl: HTMLInputElement; listEl: HTMLUListElement; statusEl: HTMLDivElement; focusedOptionEl: HTMLLIElement | null; selectedOptionEl: HTMLLIElement | null; toggleListBtnEl: HTMLButtonElement; clearInputBtnEl: HTMLButtonElement; isPristine: boolean; disableFiltering: boolean; } /** * Keymap helper - maps keyboard events to handlers * * SOURCE: Inline implementation of receptor/keymap pattern */ function keymap(mappings: Record void>) { return function (this: HTMLElement, event: KeyboardEvent) { const key = event.shiftKey ? `Shift+${event.key}` : event.key; const handler = mappings[key]; if (handler) { handler.call(this, event); } }; } /** * Set the value of the element and dispatch a change event * * SOURCE: index.js (Lines 44-54) * * @param el - The element to update * @param value - The new value */ const changeElementValue = (el: HTMLInputElement | HTMLSelectElement, value = '') => { const elementToChange = el; elementToChange.value = value; const event = new CustomEvent('change', { bubbles: true, cancelable: true, detail: { value }, }); elementToChange.dispatchEvent(event); }; /** * Get combo box context * * SOURCE: index.js (Lines 79-111) * * @param el - Element within the combo box * @returns Context object */ const getComboBoxContext = (el: HTMLElement): ComboBoxContext => { const comboBoxEl = el.closest(COMBO_BOX) as HTMLElement; if (!comboBoxEl) { throw new Error(`Element is missing outer ${COMBO_BOX}`); } const selectEl = comboBoxEl.querySelector(SELECT) as HTMLSelectElement; const inputEl = comboBoxEl.querySelector(INPUT) as HTMLInputElement; const listEl = comboBoxEl.querySelector(LIST) as HTMLUListElement; const statusEl = comboBoxEl.querySelector(STATUS) as HTMLDivElement; const focusedOptionEl = comboBoxEl.querySelector(LIST_OPTION_FOCUSED) as HTMLLIElement | null; const selectedOptionEl = comboBoxEl.querySelector(LIST_OPTION_SELECTED) as HTMLLIElement | null; const toggleListBtnEl = comboBoxEl.querySelector(TOGGLE_LIST_BUTTON) as HTMLButtonElement; const clearInputBtnEl = comboBoxEl.querySelector(CLEAR_INPUT_BUTTON) as HTMLButtonElement; const isPristine = comboBoxEl.classList.contains(COMBO_BOX_PRISTINE_CLASS); const disableFiltering = comboBoxEl.dataset.disableFiltering === 'true'; return { comboBoxEl, selectEl, inputEl, listEl, statusEl, focusedOptionEl, selectedOptionEl, toggleListBtnEl, clearInputBtnEl, isPristine, disableFiltering, }; }; /** * Disable the combo box component * * SOURCE: index.js (Lines 118-125) * * @param el - Element within the combo box */ const disable = (el: HTMLElement) => { const { inputEl, toggleListBtnEl, clearInputBtnEl } = getComboBoxContext(el); clearInputBtnEl.hidden = true; clearInputBtnEl.disabled = true; toggleListBtnEl.disabled = true; inputEl.disabled = true; }; /** * Check for aria-disabled on initialization * * SOURCE: index.js (Lines 132-139) * * @param el - Element within the combo box */ const ariaDisable = (el: HTMLElement) => { const { inputEl, toggleListBtnEl, clearInputBtnEl } = getComboBoxContext(el); clearInputBtnEl.hidden = true; clearInputBtnEl.setAttribute('aria-disabled', 'true'); toggleListBtnEl.setAttribute('aria-disabled', 'true'); inputEl.setAttribute('aria-disabled', 'true'); }; /** * Enable the combo box component * * SOURCE: index.js (Lines 146-153) * NOTE: Preserved from USWDS source but currently unused * * @param el - Element within the combo box */ // @ts-expect-error - Function preserved from USWDS source for future use // eslint-disable-next-line @typescript-eslint/no-unused-vars const enable = (el: HTMLElement) => { const { inputEl, toggleListBtnEl, clearInputBtnEl } = getComboBoxContext(el); clearInputBtnEl.hidden = false; clearInputBtnEl.disabled = false; toggleListBtnEl.disabled = false; inputEl.disabled = false; }; /** * Enhance a select element into a combo box component * * SOURCE: index.js (Lines 160-282) * * @param _comboBoxEl - Combo box element */ const enhanceComboBox = (_comboBoxEl: HTMLElement) => { const comboBoxEl = _comboBoxEl.closest(COMBO_BOX) as HTMLElement; if (comboBoxEl.dataset.enhanced === 'true') return; const selectEl = comboBoxEl.querySelector('select'); if (!selectEl) { throw new Error(`${COMBO_BOX} is missing inner select`); } const selectId = selectEl.id; const selectLabel = document.querySelector(`label[for="${selectId}"]`) as HTMLLabelElement; const listId = `${selectId}--list`; const listIdLabel = `${selectId}-label`; const additionalAttributes: Record[] = []; const { defaultValue } = comboBoxEl.dataset; const { placeholder } = comboBoxEl.dataset; let selectedOption: HTMLOptionElement | undefined; if (placeholder) { additionalAttributes.push({ placeholder }); } if (defaultValue) { for (let i = 0, len = selectEl.options.length; i < len; i += 1) { const optionEl = selectEl.options[i]; if (optionEl.value === defaultValue) { selectedOption = optionEl; break; } } } /** * Throw error if combobox is missing a label or label is missing * `for` attribute. Otherwise, set the ID to match the