From 08ed91fd1fa4773c814694f0bb2202de32488a29 Mon Sep 17 00:00:00 2001 From: Alex Morgun <87077843+oleksii-morgun@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:28:23 -0500 Subject: [PATCH] va-combo-box: add new component (#1373) * initial component plot * tweaks and styling * adding label * adding disabled prop * adding defaultValue prop * placeholder prop * format and cleanup * handle label and value change * handle required prop * update doc attributes + cleanup * update default value prop * handling hint text and aria * docs * build * styling cleanup * update docs * e2e tests * cleanup * remove disabled state from docs * focus style mixin * remove analytics props * showError prop non applicable * input styling * buttons positioning * max-width for high zoom * redo message-aria-describedby * remove not needed styling * update maturity level * console log cleanup * remove analytics event emitter * remove irrelevant error * Update components.d.ts * non-used props * e2e tests * style updates --- .../stories/va-combo-box-uswds.stories.jsx | 117 +++ packages/web-components/src/components.d.ts | 136 +++ .../va-combo-box/test/va-combo-box.e2e.ts | 189 ++++ .../va-combo-box/va-combo-box-library.js | 840 ++++++++++++++++++ .../components/va-combo-box/va-combo-box.scss | 56 ++ .../components/va-combo-box/va-combo-box.tsx | 213 +++++ 6 files changed, 1551 insertions(+) create mode 100644 packages/storybook/stories/va-combo-box-uswds.stories.jsx create mode 100644 packages/web-components/src/components/va-combo-box/test/va-combo-box.e2e.ts create mode 100644 packages/web-components/src/components/va-combo-box/va-combo-box-library.js create mode 100644 packages/web-components/src/components/va-combo-box/va-combo-box.scss create mode 100644 packages/web-components/src/components/va-combo-box/va-combo-box.tsx diff --git a/packages/storybook/stories/va-combo-box-uswds.stories.jsx b/packages/storybook/stories/va-combo-box-uswds.stories.jsx new file mode 100644 index 000000000..d84b9f203 --- /dev/null +++ b/packages/storybook/stories/va-combo-box-uswds.stories.jsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { getWebComponentDocs, propStructure, StoryDocs } from './wc-helpers'; + +const comboBoxDocs = getWebComponentDocs('va-combo-box'); + +export default { + title: 'Components/Combo Box USWDS', + id: 'uswds/va-combo-box', + parameters: { + componentSubtitle: 'va-combo-box web component', + docs: { + page: () => , + }, + }, +}; + +const defaultArgs = { + label: 'Select a fruit', + name: 'fruit', + value: '', + required: false, + error: undefined, + messageAriaDescribedby: undefined, + options: [ + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + , + + ], +}; + +const Template = ({ + label, + name, + value, + required, + error, + hint, + options, + placeholder, + disabled, + messageAriaDescribedby, +}) => { + + return ( + + {options} + + ); +}; + +export const Default = Template.bind({}); +Default.args = { ...defaultArgs }; +Default.argTypes = propStructure(comboBoxDocs); + +export const WithDefaultValue = Template.bind({}); +WithDefaultValue.args = { + ...defaultArgs, + value: 'banana', +}; + +export const WithError = Template.bind({}); +WithError.args = { + ...defaultArgs, + error: 'This field contains an error.', +}; + +export const Required = Template.bind({}); +Required.args = { + ...defaultArgs, + required: true, +}; + +export const WithPlaceholderText = Template.bind({}); +WithPlaceholderText.args = { + ...defaultArgs, + placeholder: '--Select--', +}; + +export const WithHintText = Template.bind({}); +WithHintText.args = { + ...defaultArgs, + hint: 'This is example hint text', +}; + +export const WithMessageAriaDescribedBy = Template.bind({}); +WithMessageAriaDescribedBy.args = { + ...defaultArgs, + messageAriaDescribedby: 'This is example aria message', +}; diff --git a/packages/web-components/src/components.d.ts b/packages/web-components/src/components.d.ts index 79ceb561c..e6a0774c7 100644 --- a/packages/web-components/src/components.d.ts +++ b/packages/web-components/src/components.d.ts @@ -439,6 +439,52 @@ export namespace Components { */ "useFormsPattern"?: string; } + /** + * @componentName Combo Box + * @maturityCategory caution + * @maturityLevel candidate + * @guidanceHref form/combo-box + * @translations English + * @translations Spanish + */ + interface VaComboBox { + /** + * The combo box component will be disabled / read-only. + */ + "disabled"?: boolean; + /** + * Error message to display. When defined, this indicates an error. + */ + "error"?: string; + /** + * Optional hint text. + */ + "hint"?: string; + /** + * Text label for the field. + */ + "label": string; + /** + * An optional message that will be read by screen readers when the select is focused. + */ + "messageAriaDescribedby"?: string; + /** + * Name attribute for the select field. + */ + "name": string; + /** + * The placeholder string. + */ + "placeholder"?: string; + /** + * Whether or not this is a required field. + */ + "required"?: boolean; + /** + * Selected value (will get updated on select). + */ + "value"?: string; + } /** * @componentName Crisis Line Modal * @maturityCategory caution @@ -1848,6 +1894,10 @@ export interface VaCheckboxGroupCustomEvent extends CustomEvent { detail: T; target: HTMLVaCheckboxGroupElement; } +export interface VaComboBoxCustomEvent extends CustomEvent { + detail: T; + target: HTMLVaComboBoxElement; +} export interface VaDateCustomEvent extends CustomEvent { detail: T; target: HTMLVaDateElement; @@ -2261,6 +2311,31 @@ declare global { prototype: HTMLVaCheckboxGroupElement; new (): HTMLVaCheckboxGroupElement; }; + interface HTMLVaComboBoxElementEventMap { + "vaSelect": any; + } + /** + * @componentName Combo Box + * @maturityCategory caution + * @maturityLevel candidate + * @guidanceHref form/combo-box + * @translations English + * @translations Spanish + */ + interface HTMLVaComboBoxElement extends Components.VaComboBox, HTMLStencilElement { + addEventListener(type: K, listener: (this: HTMLVaComboBoxElement, ev: VaComboBoxCustomEvent) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | AddEventListenerOptions): void; + addEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | AddEventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLVaComboBoxElement, ev: VaComboBoxCustomEvent) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: Document, ev: DocumentEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: K, listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => any, options?: boolean | EventListenerOptions): void; + removeEventListener(type: string, listener: EventListenerOrEventListenerObject, options?: boolean | EventListenerOptions): void; + } + var HTMLVaComboBoxElement: { + prototype: HTMLVaComboBoxElement; + new (): HTMLVaComboBoxElement; + }; /** * @componentName Crisis Line Modal * @maturityCategory caution @@ -3028,6 +3103,7 @@ declare global { "va-card": HTMLVaCardElement; "va-checkbox": HTMLVaCheckboxElement; "va-checkbox-group": HTMLVaCheckboxGroupElement; + "va-combo-box": HTMLVaComboBoxElement; "va-crisis-line-modal": HTMLVaCrisisLineModalElement; "va-date": HTMLVaDateElement; "va-file-input": HTMLVaFileInputElement; @@ -3572,6 +3648,56 @@ declare namespace LocalJSX { */ "useFormsPattern"?: string; } + /** + * @componentName Combo Box + * @maturityCategory caution + * @maturityLevel candidate + * @guidanceHref form/combo-box + * @translations English + * @translations Spanish + */ + interface VaComboBox { + /** + * The combo box component will be disabled / read-only. + */ + "disabled"?: boolean; + /** + * Error message to display. When defined, this indicates an error. + */ + "error"?: string; + /** + * Optional hint text. + */ + "hint"?: string; + /** + * Text label for the field. + */ + "label": string; + /** + * An optional message that will be read by screen readers when the select is focused. + */ + "messageAriaDescribedby"?: string; + /** + * Name attribute for the select field. + */ + "name": string; + /** + * The event emitted when the selected value changes + */ + "onVaSelect"?: (event: VaComboBoxCustomEvent) => void; + /** + * The placeholder string. + */ + "placeholder"?: string; + /** + * Whether or not this is a required field. + */ + "required"?: boolean; + /** + * Selected value (will get updated on select). + */ + "value"?: string; + } /** * @componentName Crisis Line Modal * @maturityCategory caution @@ -5128,6 +5254,7 @@ declare namespace LocalJSX { "va-card": VaCard; "va-checkbox": VaCheckbox; "va-checkbox-group": VaCheckboxGroup; + "va-combo-box": VaComboBox; "va-crisis-line-modal": VaCrisisLineModal; "va-date": VaDate; "va-file-input": VaFileInput; @@ -5268,6 +5395,15 @@ declare module "@stencil/core" { * @translations Tagalog */ "va-checkbox-group": LocalJSX.VaCheckboxGroup & JSXBase.HTMLAttributes; + /** + * @componentName Combo Box + * @maturityCategory caution + * @maturityLevel candidate + * @guidanceHref form/combo-box + * @translations English + * @translations Spanish + */ + "va-combo-box": LocalJSX.VaComboBox & JSXBase.HTMLAttributes; /** * @componentName Crisis Line Modal * @maturityCategory caution diff --git a/packages/web-components/src/components/va-combo-box/test/va-combo-box.e2e.ts b/packages/web-components/src/components/va-combo-box/test/va-combo-box.e2e.ts new file mode 100644 index 000000000..f3b23b7b8 --- /dev/null +++ b/packages/web-components/src/components/va-combo-box/test/va-combo-box.e2e.ts @@ -0,0 +1,189 @@ +import { newE2EPage } from '@stencil/core/testing'; + +describe('va-combo-box', () => { + it('renders', async () => { + const page = await newE2EPage(); + + await page.setContent(` + + + + + `); + const element = await page.find('va-combo-box'); + await page.find('va-combo-box >>> input'); + expect(element).toEqualHtml(` + + + + + +
+ + + + + + + + + + +
+ + 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
    aria-labelledby + */ + if (!selectLabel || !selectLabel.matches(`label[for="${selectId}"]`)) { + throw new Error( + `${COMBO_BOX} for ${selectId} is either missing a label or a "for" attribute`, + ); + } else { + selectLabel.setAttribute('id', listIdLabel); + } + + selectLabel.setAttribute('id', listIdLabel); + selectEl.setAttribute('aria-hidden', 'true'); + selectEl.setAttribute('tabindex', '-1'); + selectEl.classList.add('usa-sr-only', SELECT_CLASS); + selectEl.id = ''; + selectEl.value = ''; + + ['required', 'aria-label', 'aria-labelledby'].forEach(name => { + if (selectEl.hasAttribute(name)) { + const value = selectEl.getAttribute(name); + additionalAttributes.push({ [name]: value }); + selectEl.removeAttribute(name); + } + }); + + // sanitize doesn't like functions in template literals + const input = document.createElement('input'); + input.setAttribute('id', selectId); + input.setAttribute('aria-owns', listId); + input.setAttribute('aria-controls', listId); + input.setAttribute('aria-autocomplete', 'list'); + input.setAttribute('aria-describedby', assistiveHintID); + input.setAttribute('aria-expanded', 'false'); + input.setAttribute('autocapitalize', 'off'); + input.setAttribute('autocomplete', 'off'); + input.setAttribute('class', INPUT_CLASS); + input.setAttribute('type', 'text'); + input.setAttribute('role', 'combobox'); + additionalAttributes.forEach(attr => + Object.keys(attr).forEach(key => { + const value = Sanitizer.escapeHTML`${attr[key]}`; + input.setAttribute(key, value); + }), + ); + + comboBoxEl.insertAdjacentElement('beforeend', input); + + comboBoxEl.insertAdjacentHTML( + 'beforeend', + Sanitizer.escapeHTML` + + + +   + + + + +
    + + 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. + `, + ); + + if (selectedOption) { + const { inputEl } = getComboBoxContext(comboBoxEl); + changeElementValue(selectEl, selectedOption.value); + changeElementValue(inputEl, selectedOption.text); + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + } + + if (selectEl.disabled) { + disable(comboBoxEl); + selectEl.disabled = false; + } + + if (selectEl.hasAttribute('aria-disabled')) { + ariaDisable(comboBoxEl); + selectEl.removeAttribute('aria-disabled'); + } + + comboBoxEl.dataset.enhanced = 'true'; + }; + + /** + * Manage the focused element within the list options when + * navigating via keyboard. + * + * @param {HTMLElement} el An anchor element within the combo box component + * @param {HTMLElement} nextEl An element within the combo box component + * @param {Object} options options + * @param {boolean} options.skipFocus skip focus of highlighted item + * @param {boolean} options.preventScroll should skip procedure to scroll to element + */ + const highlightOption = (el, nextEl, { skipFocus, preventScroll } = {}) => { + const { inputEl, listEl, focusedOptionEl } = getComboBoxContext(el); + + if (focusedOptionEl) { + focusedOptionEl.classList.remove(LIST_OPTION_FOCUSED_CLASS); + focusedOptionEl.setAttribute('tabIndex', '-1'); + } + + if (nextEl) { + inputEl.setAttribute('aria-activedescendant', nextEl.id); + nextEl.setAttribute('tabIndex', '0'); + nextEl.classList.add(LIST_OPTION_FOCUSED_CLASS); + + if (!preventScroll) { + const optionBottom = nextEl.offsetTop + nextEl.offsetHeight; + const currentBottom = listEl.scrollTop + listEl.offsetHeight; + + if (optionBottom > currentBottom) { + listEl.scrollTop = optionBottom - listEl.offsetHeight; + } + + if (nextEl.offsetTop < listEl.scrollTop) { + listEl.scrollTop = nextEl.offsetTop; + } + } + + if (!skipFocus) { + nextEl.focus({ preventScroll }); + } + } else { + inputEl.setAttribute('aria-activedescendant', ''); + inputEl.focus(); + } + }; + + /** + * Generate a dynamic regular expression based off of a replaceable and possibly filtered value. + * + * @param {string} el An element within the combo box component + * @param {string} query The value to use in the regular expression + * @param {object} extras An object of regular expressions to replace and filter the query + */ + const generateDynamicRegExp = (filter, query = '', extras = {}) => { + const escapeRegExp = text => + text.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, '\\$&'); + + let find = filter.replace(/{{(.*?)}}/g, (m, $1) => { + const key = $1.trim(); + const queryFilter = extras[key]; + if (key !== 'query' && queryFilter) { + const matcher = new RegExp(queryFilter, 'i'); + const matches = query.match(matcher); + + if (matches) { + return escapeRegExp(matches[1]); + } + + return ''; + } + return escapeRegExp(query); + }); + + find = `^(?:${find})$`; + + return new RegExp(find, 'i'); + }; + + /** + * Display the option list of a combo box component. + * + * @param {HTMLElement} el An element within the combo box component + */ + const displayList = el => { + const { + comboBoxEl, + selectEl, + inputEl, + listEl, + statusEl, + isPristine, + disableFiltering, + } = getComboBoxContext(el); + let selectedItemId; + let firstFoundId; + + const listOptionBaseId = `${listEl.id}--option-`; + + const inputValue = (inputEl.value || '').toLowerCase(); + const filter = comboBoxEl.dataset.filter || DEFAULT_FILTER; + const regex = generateDynamicRegExp(filter, inputValue, comboBoxEl.dataset); + + const options = []; + for (let i = 0, len = selectEl.options.length; i < len; i += 1) { + const optionEl = selectEl.options[i]; + const optionId = `${listOptionBaseId}${options.length}`; + + if ( + optionEl.value && + (disableFiltering || + isPristine || + !inputValue || + regex.test(optionEl.text)) + ) { + if (selectEl.value && optionEl.value === selectEl.value) { + selectedItemId = optionId; + } + + if (disableFiltering && !firstFoundId && regex.test(optionEl.text)) { + firstFoundId = optionId; + } + options.push(optionEl); + } + } + + const numOptions = options.length; + const optionHtml = options.map((option, index) => { + const optionId = `${listOptionBaseId}${index}`; + const classes = [LIST_OPTION_CLASS]; + let tabindex = '-1'; + let ariaSelected = 'false'; + + if (optionId === selectedItemId) { + classes.push(LIST_OPTION_SELECTED_CLASS, LIST_OPTION_FOCUSED_CLASS); + tabindex = '0'; + ariaSelected = 'true'; + } + + if (!selectedItemId && index === 0) { + classes.push(LIST_OPTION_FOCUSED_CLASS); + tabindex = '0'; + } + + const li = document.createElement('li'); + + li.setAttribute('aria-setsize', options.length); + li.setAttribute('aria-posinset', index + 1); + li.setAttribute('aria-selected', ariaSelected); + li.setAttribute('id', optionId); + li.setAttribute('class', classes.join(' ')); + li.setAttribute('tabindex', tabindex); + li.setAttribute('role', 'option'); + li.setAttribute('data-value', option.value); + li.textContent = option.text; + + return li; + }); + + const noResults = document.createElement('li'); + noResults.setAttribute('class', `${LIST_OPTION_CLASS}--no-results`); + noResults.textContent = 'No results found'; + + listEl.hidden = false; + + if (numOptions) { + listEl.innerHTML = ''; + optionHtml.forEach(item => + listEl.insertAdjacentElement('beforeend', item), + ); + } else { + listEl.innerHTML = ''; + listEl.insertAdjacentElement('beforeend', noResults); + } + + inputEl.setAttribute('aria-expanded', 'true'); + + statusEl.textContent = numOptions + ? `${numOptions} result${numOptions > 1 ? 's' : ''} available.` + : 'No results.'; + + let itemToFocus; + + if (isPristine && selectedItemId) { + itemToFocus = listEl.querySelector(`#${selectedItemId}`); + } else if (disableFiltering && firstFoundId) { + itemToFocus = listEl.querySelector(`#${firstFoundId}`); + } + + if (itemToFocus) { + highlightOption(listEl, itemToFocus, { + skipFocus: true, + }); + } + }; + + /** + * Hide the option list of a combo box component. + * + * @param {HTMLElement} el An element within the combo box component + */ + const hideList = el => { + const { inputEl, listEl, statusEl, focusedOptionEl } = + getComboBoxContext(el); + + statusEl.innerHTML = ''; + + inputEl.setAttribute('aria-expanded', 'false'); + inputEl.setAttribute('aria-activedescendant', ''); + + if (focusedOptionEl) { + focusedOptionEl.classList.remove(LIST_OPTION_FOCUSED_CLASS); + } + + listEl.scrollTop = 0; + listEl.hidden = true; + }; + + /** + * Select an option list of the combo box component. + * + * @param {HTMLElement} listOptionEl The list option being selected + */ + const selectItem = listOptionEl => { + const { comboBoxEl, selectEl, inputEl } = getComboBoxContext(listOptionEl); + + changeElementValue(selectEl, listOptionEl.dataset.value); + changeElementValue(inputEl, listOptionEl.textContent); + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + hideList(comboBoxEl); + inputEl.focus(); + }; + + /** + * Clear the input of the combo box + * + * @param {HTMLButtonElement} clearButtonEl The clear input button + */ + const clearInput = clearButtonEl => { + const { comboBoxEl, listEl, selectEl, inputEl } = + getComboBoxContext(clearButtonEl); + const listShown = !listEl.hidden; + + if (selectEl.value) changeElementValue(selectEl); + if (inputEl.value) changeElementValue(inputEl); + comboBoxEl.classList.remove(COMBO_BOX_PRISTINE_CLASS); + + if (listShown) displayList(comboBoxEl); + inputEl.focus(); + }; + + /** + * Reset the select based off of currently set select value + * + * @param {HTMLElement} el An element within the combo box component + */ + const resetSelection = el => { + const { comboBoxEl, selectEl, inputEl } = getComboBoxContext(el); + + const selectValue = selectEl.value; + const inputValue = (inputEl.value || '').toLowerCase(); + + if (selectValue) { + for (let i = 0, len = selectEl.options.length; i < len; i += 1) { + const optionEl = selectEl.options[i]; + if (optionEl.value === selectValue) { + if (inputValue !== optionEl.text) { + changeElementValue(inputEl, optionEl.text); + } + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + return; + } + } + } + + if (inputValue) { + changeElementValue(inputEl); + } + }; + + /** + * Select an option list of the combo box component based off of + * having a current focused list option or + * having test that completely matches a list option. + * Otherwise it clears the input and select. + * + * @param {HTMLElement} el An element within the combo box component + */ + const completeSelection = el => { + const { comboBoxEl, selectEl, inputEl, statusEl } = getComboBoxContext(el); + + statusEl.textContent = ''; + + const inputValue = (inputEl.value || '').toLowerCase(); + + if (inputValue) { + for (let i = 0, len = selectEl.options.length; i < len; i += 1) { + const optionEl = selectEl.options[i]; + if (optionEl.text.toLowerCase() === inputValue) { + changeElementValue(selectEl, optionEl.value); + changeElementValue(inputEl, optionEl.text); + comboBoxEl.classList.add(COMBO_BOX_PRISTINE_CLASS); + return; + } + } + } + + resetSelection(comboBoxEl); + }; + + /** + * Handle the escape event within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ + const handleEscape = event => { + const { comboBoxEl, inputEl } = getComboBoxContext(event.target); + + hideList(comboBoxEl); + resetSelection(comboBoxEl); + inputEl.focus(); + }; + + /** + * Handle the down event within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ + const handleDownFromInput = event => { + const { comboBoxEl, listEl } = getComboBoxContext(event.target); + + if (listEl.hidden) { + displayList(comboBoxEl); + } + + const nextOptionEl = + listEl.querySelector(LIST_OPTION_FOCUSED) || + listEl.querySelector(LIST_OPTION); + + if (nextOptionEl) { + highlightOption(comboBoxEl, nextOptionEl); + } + + event.preventDefault(); + }; + + /** + * Handle the enter event from an input element within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ + const handleEnterFromInput = event => { + const { comboBoxEl, listEl } = getComboBoxContext(event.target); + const listShown = !listEl.hidden; + + completeSelection(comboBoxEl); + + if (listShown) { + hideList(comboBoxEl); + } + + event.preventDefault(); + }; + + /** + * Handle the down event within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ + const handleDownFromListOption = event => { + const focusedOptionEl = event.target; + const nextOptionEl = focusedOptionEl.nextSibling; + + if (nextOptionEl) { + highlightOption(focusedOptionEl, nextOptionEl); + } + + event.preventDefault(); + }; + + /** + * Handle the space event from an list option element within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ + const handleSpaceFromListOption = event => { + selectItem(event.target); + event.preventDefault(); + }; + + /** + * Handle the enter event from list option within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ + const handleEnterFromListOption = event => { + selectItem(event.target); + event.preventDefault(); + }; + + /** + * Handle the up event from list option within the combo box component. + * + * @param {KeyboardEvent} event An event within the combo box component + */ + const handleUpFromListOption = event => { + const { comboBoxEl, listEl, focusedOptionEl } = getComboBoxContext( + event.target, + ); + const nextOptionEl = focusedOptionEl && focusedOptionEl.previousSibling; + const listShown = !listEl.hidden; + + highlightOption(comboBoxEl, nextOptionEl); + + if (listShown) { + event.preventDefault(); + } + + if (!nextOptionEl) { + hideList(comboBoxEl); + } + }; + + /** + * Select list option on the mouseover event. + * + * @param {MouseEvent} event The mouseover event + * @param {HTMLLIElement} listOptionEl An element within the combo box component + */ + const handleMouseover = listOptionEl => { + const isCurrentlyFocused = listOptionEl.classList.contains( + LIST_OPTION_FOCUSED_CLASS, + ); + + if (isCurrentlyFocused) return; + + highlightOption(listOptionEl, listOptionEl, { + preventScroll: true, + }); + }; + + /** + * Toggle the list when the button is clicked + * + * @param {HTMLElement} el An element within the combo box component + */ + const toggleList = el => { + const { comboBoxEl, listEl, inputEl } = getComboBoxContext(el); + + if (listEl.hidden) { + displayList(comboBoxEl); + } else { + hideList(comboBoxEl); + } + + inputEl.focus(); + }; + + /** + * Handle click from input + * + * @param {HTMLInputElement} el An element within the combo box component + */ + const handleClickFromInput = el => { + const { comboBoxEl, listEl } = getComboBoxContext(el); + + if (listEl.hidden) { + displayList(comboBoxEl); + } + }; + + export const comboBox = behavior( + { + [CLICK]: { + [INPUT]() { + if (this.disabled) return; + handleClickFromInput(this); + }, + [TOGGLE_LIST_BUTTON]() { + if (this.disabled) return; + toggleList(this); + }, + [LIST_OPTION]() { + if (this.disabled) return; + selectItem(this); + }, + [CLEAR_INPUT_BUTTON]() { + if (this.disabled) return; + clearInput(this); + }, + }, + focusout: { + [COMBO_BOX](event) { + if (!this.contains(event.relatedTarget)) { + resetSelection(this); + hideList(this); + } + }, + }, + keydown: { + [COMBO_BOX]: keymap({ + Escape: handleEscape, + }), + [INPUT]: keymap({ + Enter: handleEnterFromInput, + ArrowDown: handleDownFromInput, + Down: handleDownFromInput, + }), + [LIST_OPTION]: keymap({ + 'ArrowUp': handleUpFromListOption, + 'Up': handleUpFromListOption, + 'ArrowDown': handleDownFromListOption, + 'Down': handleDownFromListOption, + 'Enter': handleEnterFromListOption, + ' ': handleSpaceFromListOption, + 'Shift+Tab': noop, + }), + }, + input: { + [INPUT]() { + const comboBoxEl = this.closest(COMBO_BOX); + comboBoxEl.classList.remove(COMBO_BOX_PRISTINE_CLASS); + displayList(this); + }, + }, + mouseover: { + [LIST_OPTION]() { + handleMouseover(this); + }, + }, + }, + { + init(root, labelEl) { + selectOrMatches(COMBO_BOX, root).forEach(comboBoxEl => { + enhanceComboBox(comboBoxEl, labelEl); + }); + }, + getComboBoxContext, + enhanceComboBox, + generateDynamicRegExp, + disable, + enable, + displayList, + hideList, + COMBO_BOX_CLASS, + }, + ) + + // module.exports = comboBox; diff --git a/packages/web-components/src/components/va-combo-box/va-combo-box.scss b/packages/web-components/src/components/va-combo-box/va-combo-box.scss new file mode 100644 index 000000000..d2ad147ea --- /dev/null +++ b/packages/web-components/src/components/va-combo-box/va-combo-box.scss @@ -0,0 +1,56 @@ +@forward 'settings'; + +@use 'usa-label/src/styles/usa-label'; +@use 'usa-error-message/src/styles/usa-error-message'; +@use 'usa-combo-box/src/styles'; +@use 'uswds-helpers/src/styles/usa-sr-only'; +@use 'usa-icon/src/styles/usa-icon'; +@use 'usa-select/src/styles/usa-select'; +@use 'usa-hint/src/styles/usa-hint'; + +@import '../../mixins/uswds-input-width.scss'; +@import '../../mixins/uswds-error-border.scss'; +@import '../../mixins/focusable.css'; +@import '../../mixins/inputs.css'; + + +:host { + display: block; + font-family: var(--font-source-sans); + color: var(--vads-color-base); + max-width: 30rem; +} + +:host([inert]) select { + border: 0; + background: none; +} + +.usa-select { + margin-bottom: 0; +} + +::slotted(option), +::slotted(optgroup){ + display: none; +} + +.usa-combo-box__list{ + width: calc(100% - 2px); +} + +.usa-combo-box__list-option--focused{ + outline: 2px solid var(--vads-color-action-focus-on-light); +} + +.usa-combo-box__list-option--focused:focus { + outline-offset: -2px; +} + +.usa-combo-box__clear-input{ + right: calc(2.5rem + 3px); +} + +.usa-combo-box__input-button-separator { + right: calc(2.5rem + 2px); +} diff --git a/packages/web-components/src/components/va-combo-box/va-combo-box.tsx b/packages/web-components/src/components/va-combo-box/va-combo-box.tsx new file mode 100644 index 000000000..4fa87e418 --- /dev/null +++ b/packages/web-components/src/components/va-combo-box/va-combo-box.tsx @@ -0,0 +1,213 @@ +import { + Component, + Element, + Event, + EventEmitter, + forceUpdate, + Host, + Prop, + h, + State, + Fragment, +} from '@stencil/core'; +import classnames from 'classnames'; +import { i18next } from '../..'; +import { comboBox } from './va-combo-box-library.js'; + +/** + * @componentName Combo Box + * @maturityCategory caution + * @maturityLevel candidate + * @guidanceHref form/combo-box + * @translations English + * @translations Spanish + */ + +@Component({ + tag: 'va-combo-box', + styleUrl: 'va-combo-box.scss', + shadow: true, +}) +export class VaComboBox { + @Element() el: any; + + @State() options: Array; + + @State() labelNode: HTMLLabelElement; + + /** + * Whether or not this is a required field. + */ + @Prop() required?: boolean = false; + + /** + * The combo box component will be disabled / read-only. + */ + @Prop() disabled?: boolean = false; + + /** + * Text label for the field. + */ + @Prop() label!: string; + + /** + * The placeholder string. + */ + @Prop() placeholder?: string; + + /** + * Name attribute for the select field. + */ + @Prop() name!: string; + + /** + * Selected value (will get updated on select). + */ + @Prop({ reflect: true, mutable: true }) value?: string; + + /** + * Error message to display. When defined, this indicates an error. + */ + @Prop() error?: string; + + /** + * Optional hint text. + */ + @Prop() hint?: string; + + /** + * An optional message that will be read by screen readers when the select is focused. + */ + @Prop() messageAriaDescribedby?: string; + + /** + * The event emitted when the selected value changes + */ + @Event() vaSelect: EventEmitter; + + + connectedCallback() { + i18next.on('languageChanged', () => { + forceUpdate(this.el); + }); + } + + componentDidLoad() { + const comboBoxElement = this.el.shadowRoot.querySelector('.usa-combo-box'); + const labelElement = this.el.shadowRoot.querySelector('label'); + if (comboBoxElement) { + comboBox.init(comboBoxElement, labelElement); + comboBox.on(comboBoxElement); + } + const inputElement = this.el.shadowRoot.querySelector('input'); + if (inputElement && this.error) { + inputElement.classList.add('usa-input--error'); + } + + const errorID = 'input-error-message'; + const ariaDescribedbyIds = + `${this.messageAriaDescribedby ? 'input-message' : ''} ${ + this.error ? errorID : '' + } ${this.hint ? 'input-hint' : ''} ${inputElement.getAttribute('aria-describedby')}`.trim() ; + // need to append to existing attribute which is set during initialization and contains USWDS "options--assistiveHint" + inputElement.setAttribute('aria-describedby', ariaDescribedbyIds); + } + + disconnectedCallback() { + i18next.off('languageChanged'); + // Clean up the USWDS ComboBox + const comboBoxElement = this.el.shadowRoot.querySelector('.usa-combo-box'); + if (comboBoxElement) { + comboBox.off(comboBoxElement); + } + } + + /** + * This function is for taking the slotted content and rendering + * it inside the `` doesn't actually show the ` + ); + }, + ); + } + + private handleChange(e: Event) { + const target: HTMLSelectElement = e.target as HTMLSelectElement; + this.value = target.value; + this.vaSelect.emit({ value: this.value }); + } + + render() { + const { + error, + value, + disabled, + placeholder, + label, + required, + name, + hint, + messageAriaDescribedby, + } = this; + + const labelClass = classnames({ + 'usa-label': true, + 'usa-label--error': error, + }); + return ( + + + {hint && ( + + {hint} + + )} + + {error && ( + + {i18next.t('error')} + {error} + + )} + + +
    + +
    + {messageAriaDescribedby && ( + + {messageAriaDescribedby} + + )} +
    + ); + } +}