+
+
+ When autocomplete results are available use up and down arrows to review and enter to select. Touch device users, explore by touch or with swipe gestures.
+
+
+
+
+
+ `);
+ });
+
+ it('renders default value', async () => {
+ const page = await newE2EPage();
+
+ await page.setContent(`
+
+
+
+
+ `);
+
+ await page.find('va-combo-box >>> input');
+ const combobox = await page.find('va-combo-box >>> .usa-combo-box');
+ expect(combobox).toEqualAttribute('data-default-value', 'bar');
+ });
+
+ it('renders error message', async () => {
+ const page = await newE2EPage();
+
+ await page.setContent(`
+
+
+
+
+ `);
+ const input = await page.find('va-combo-box >>> input');
+ const element = await page.find('va-combo-box >>> #input-error-message');
+ expect(element).toEqualHtml(`
+ Error
+ test error message
+ `);
+ expect(input).toEqualAttribute('aria-describedby', 'input-error-message options--assistiveHint');
+ });
+
+ it('renders label with required', async () => {
+ const page = await newE2EPage();
+
+ await page.setContent(`
+
+
+
+
+ `);
+ await page.find('va-combo-box >>> input');
+ const element = await page.find('va-combo-box >>> label');
+ expect(element)
+ .toEqualHtml(``);
+ });
+
+ it('renders placeholder text ', async () => {
+ const page = await newE2EPage();
+
+ await page.setContent(`
+
+
+
+
+ `);
+ await page.find('va-combo-box >>> input');
+ const element = await page.find('va-combo-box >>> input');
+ expect(element).toEqualAttribute('placeholder', 'test placeholder');
+ });
+
+ it('renders hint text ', async () => {
+ const page = await newE2EPage();
+ const hintText = 'test hint';
+ const labelText = 'A label';
+
+ await page.setContent(`
+
+
+
+
+ `);
+ await page.find('va-combo-box >>> input');
+
+ const hintElement = await page.find('va-combo-box >>> #input-hint');
+ expect(hintElement).toEqualText('test hint');
+ expect(hintElement).toEqualHtml(`
+ ${hintText}
+ `);
+ const element = await page.find('va-combo-box');
+
+ // validate that the label is followed by the hint
+ expect(element.shadowRoot.innerHTML).toContain(
+ `${hintText}`,
+ );
+ });
+
+ it('renders disabled', async () => {
+ const page = await newE2EPage();
+
+ await page.setContent(`
+
+
+
+
+ `);
+ await page.find('va-combo-box >>> input');
+ const element = await page.find('va-combo-box >>> input');
+ expect(element).toHaveAttribute('disabled');
+ });
+
+ it('renders name property', async () => {
+ const page = await newE2EPage();
+
+ await page.setContent(`
+
+
+
+
+ `);
+ await page.find('va-combo-box >>> input');
+ const element = await page.find('va-combo-box >>> select');
+ expect(element).toEqualAttribute('name', 'test-name');
+ });
+
+ it('renders message-aria-describedby', async () => {
+ const page = await newE2EPage();
+
+ await page.setContent(`
+
+
+
+
+ `);
+ const input = await page.find('va-combo-box >>> input');
+ const element = await page.find('va-combo-box >>> #input-message');
+ expect(element).toEqualHtml('test aria text');
+ expect(input).toEqualAttribute('aria-describedby', 'input-message options--assistiveHint');
+ });
+});
diff --git a/packages/web-components/src/components/va-combo-box/va-combo-box-library.js b/packages/web-components/src/components/va-combo-box/va-combo-box-library.js
new file mode 100644
index 000000000..0ac016a52
--- /dev/null
+++ b/packages/web-components/src/components/va-combo-box/va-combo-box-library.js
@@ -0,0 +1,840 @@
+import keymap from 'receptor/keymap';
+import selectOrMatches from '@uswds/uswds/packages/uswds-core/src/js/utils/select-or-matches';
+import behavior from '@uswds/uswds/packages/uswds-core/src/js/utils/behavior';
+import Sanitizer from '@uswds/uswds/packages/uswds-core/src/js/utils/sanitizer';
+import { CLICK } from '@uswds/uswds/packages/uswds-core/src/js/events';
+
+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 = () => {};
+
+ /**
+ * set the value of the element and dispatch a change event
+ *
+ * @param {HTMLInputElement|HTMLSelectElement} el The element to update
+ * @param {string} value The new value of the element
+ */
+ const changeElementValue = (el, value = '') => {
+ const elementToChange = el;
+ elementToChange.value = value;
+
+ const event = new CustomEvent('change', {
+ bubbles: true,
+ cancelable: true,
+ detail: { value },
+ });
+ elementToChange.dispatchEvent(event);
+ };
+
+ /**
+ * The elements within the combo box.
+ * @typedef {Object} ComboBoxContext
+ * @property {HTMLElement} comboBoxEl
+ * @property {HTMLSelectElement} selectEl
+ * @property {HTMLInputElement} inputEl
+ * @property {HTMLUListElement} listEl
+ * @property {HTMLDivElement} statusEl
+ * @property {HTMLLIElement} focusedOptionEl
+ * @property {HTMLLIElement} selectedOptionEl
+ * @property {HTMLButtonElement} toggleListBtnEl
+ * @property {HTMLButtonElement} clearInputBtnEl
+ * @property {boolean} isPristine
+ * @property {boolean} disableFiltering
+ */
+
+ /**
+ * Get an object of elements belonging directly to the given
+ * combo box component.
+ *
+ * @param {HTMLElement} el the element within the combo box
+ * @returns {ComboBoxContext} elements
+ */
+ const getComboBoxContext = el => {
+ const comboBoxEl = el.closest(COMBO_BOX);
+
+ if (!comboBoxEl) {
+ throw new Error(`Element is missing outer ${COMBO_BOX}`);
+ }
+
+ const selectEl = comboBoxEl.querySelector(SELECT);
+ const inputEl = comboBoxEl.querySelector(INPUT);
+ const listEl = comboBoxEl.querySelector(LIST);
+ const statusEl = comboBoxEl.querySelector(STATUS);
+ const focusedOptionEl = comboBoxEl.querySelector(LIST_OPTION_FOCUSED);
+ const selectedOptionEl = comboBoxEl.querySelector(LIST_OPTION_SELECTED);
+ const toggleListBtnEl = comboBoxEl.querySelector(TOGGLE_LIST_BUTTON);
+ const clearInputBtnEl = comboBoxEl.querySelector(CLEAR_INPUT_BUTTON);
+
+ 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
+ *
+ * @param {HTMLInputElement} el An element within the combo box component
+ */
+ const disable = el => {
+ const { inputEl, toggleListBtnEl, clearInputBtnEl } =
+ getComboBoxContext(el);
+
+ clearInputBtnEl.hidden = true;
+ clearInputBtnEl.disabled = true;
+ toggleListBtnEl.disabled = true;
+ inputEl.disabled = true;
+ };
+
+ /**
+ * Check for aria-disabled on initialization
+ *
+ * @param {HTMLInputElement} el An element within the combo box component
+ */
+ const ariaDisable = el => {
+ 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
+ *
+ * @param {HTMLInputElement} el An element within the combo box component
+ */
+ const enable = el => {
+ 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.
+ *
+ * @param {HTMLElement} _comboBoxEl The initial element of the combo box component
+ */
+ const enhanceComboBox = (_comboBoxEl, _labelEl) => {
+ const comboBoxEl = _comboBoxEl.closest(COMBO_BOX);
+
+ if (comboBoxEl.dataset.enhanced) return;
+
+ const selectEl = comboBoxEl.querySelector('select');
+
+ if (!selectEl) {
+ throw new Error(`${COMBO_BOX} is missing inner select`);
+ }
+
+ const selectId = selectEl.id;
+ const selectLabel = _labelEl;
+ const listId = `${selectId}--list`;
+ const listIdLabel = `${selectId}-label`;
+ const assistiveHintID = `${selectId}--assistiveHint`;
+ const additionalAttributes = [];
+ const { defaultValue } = comboBoxEl.dataset;
+ const { placeholder } = comboBoxEl.dataset;
+ let selectedOption;
+
+ 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