From b4d949ee174fe33f07c27e5169ba9f7f144c06cd Mon Sep 17 00:00:00 2001 From: Patrick Cate Date: Sat, 11 Jun 2022 23:57:30 -0400 Subject: [PATCH] feat(UsaComboBox): implement `UsaComboBox` component ISSUES CLOSED: #97 --- cypress/plugins/index.js | 1 + .../UsaComboBox/UsaComboBox.fixtures.js | 75 + .../UsaComboBox/UsaComboBox.stories.js | 242 ++++ .../UsaComboBox/UsaComboBox.test.js | 1260 +++++++++++++++++ src/components/UsaComboBox/UsaComboBox.vue | 273 ++++ src/components/UsaComboBox/index.js | 4 + src/components/index.js | 21 +- src/composables/useComboBox.js | 449 ++++++ src/utils/common.js | 3 + 9 files changed, 2318 insertions(+), 10 deletions(-) create mode 100644 src/components/UsaComboBox/UsaComboBox.fixtures.js create mode 100644 src/components/UsaComboBox/UsaComboBox.stories.js create mode 100644 src/components/UsaComboBox/UsaComboBox.test.js create mode 100644 src/components/UsaComboBox/UsaComboBox.vue create mode 100644 src/components/UsaComboBox/index.js create mode 100644 src/composables/useComboBox.js diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index 6772a7b9..90881e49 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -51,6 +51,7 @@ module.exports = (on, config) => { '^src/**', '**/*.test.js', '**/*.stories.js', + '**/*.fixtures.js', ], cypress: true, requireEnv: false, diff --git a/src/components/UsaComboBox/UsaComboBox.fixtures.js b/src/components/UsaComboBox/UsaComboBox.fixtures.js new file mode 100644 index 00000000..44d22690 --- /dev/null +++ b/src/components/UsaComboBox/UsaComboBox.fixtures.js @@ -0,0 +1,75 @@ +export const testData = [ + { value: 'apple', label: 'Apple' }, + { value: 'apricot', label: 'Apricot' }, + { value: 'avocado', label: 'Avocado' }, + { value: 'banana', label: 'Banana' }, + { value: 'blackberry', label: 'Blackberry' }, + { value: 'blood orange', label: 'Blood orange' }, + { value: 'blueberry', label: 'Blueberry' }, + { value: 'boysenberry', label: 'Boysenberry' }, + { value: 'breadfruit', label: 'Breadfruit' }, + { value: 'buddhas hand citron', label: "Buddha's hand citron" }, + { value: 'cantaloupe', label: 'Cantaloupe' }, + { value: 'clementine', label: 'Clementine' }, + { value: 'crab apple', label: 'Crab apple' }, + { value: 'currant', label: 'Currant' }, + { value: 'cherry', label: 'Cherry' }, + { value: 'custard apple', label: 'Custard apple' }, + { value: 'coconut', label: 'Coconut' }, + { value: 'cranberry', label: 'Cranberry' }, + { value: 'date', label: 'Date' }, + { value: 'dragonfruit', label: 'Dragonfruit' }, + { value: 'durian', label: 'Durian' }, + { value: 'elderberry', label: 'Elderberry' }, + { value: 'fig', label: 'Fig' }, + { value: 'gooseberry', label: 'Gooseberry' }, + { value: 'grape', label: 'Grape' }, + { value: 'grapefruit', label: 'Grapefruit' }, + { value: 'guava', label: 'Guava' }, + { value: 'honeydew melon', label: 'Honeydew melon' }, + { value: 'jackfruit', label: 'Jackfruit' }, + { value: 'kiwifruit', label: 'Kiwifruit' }, + { value: 'kumquat', label: 'Kumquat' }, + { value: 'lemon', label: 'Lemon' }, + { value: 'lime', label: 'Lime' }, + { value: 'lychee', label: 'Lychee' }, + { value: 'mandarine', label: 'Mandarine' }, + { value: 'mango', label: 'Mango' }, + { value: 'mangosteen', label: 'Mangosteen' }, + { value: 'marionberry', label: 'Marionberry' }, + { value: 'nectarine', label: 'Nectarine' }, + { value: 'orange', label: 'Orange' }, + { value: 'papaya', label: 'Papaya' }, + { value: 'passionfruit', label: 'Passionfruit' }, + { value: 'peach', label: 'Peach' }, + { value: 'pear', label: 'Pear' }, + { value: 'persimmon', label: 'Persimmon' }, + { value: 'plantain', label: 'Plantain' }, + { value: 'plum', label: 'Plum' }, + { value: 'pineapple', label: 'Pineapple' }, + { value: 'pluot', label: 'Pluot' }, + { value: 'pomegranate', label: 'Pomegranate' }, + { value: 'pomelo', label: 'Pomelo' }, + { value: 'quince', label: 'Quince' }, + { value: 'raspberry', label: 'Raspberry' }, + { value: 'rambutan', label: 'Rambutan' }, + { value: 'soursop', label: 'Soursop' }, + { value: 'starfruit', label: 'Starfruit' }, + { value: 'strawberry', label: 'Strawberry' }, + { value: 'tamarind', label: 'Tamarind' }, + { value: 'tangelo', label: 'Tangelo' }, + { value: 'tangerine', label: 'Tangerine' }, + { value: 'ugli fruit', label: 'Ugli fruit' }, + { value: 'watermelon', label: 'Watermelon' }, + { value: 'white currant', label: 'White currant' }, + { value: 'yuzu', label: 'Yuzu' }, +] + +export const falsyTestData = [ + { value: '', label: '' }, + { value: 0, label: 0 }, + { value: 1, label: 1 }, + { value: 2, label: 1 }, + { value: 3, label: 1 }, + { value: 4, label: 1 }, +] diff --git a/src/components/UsaComboBox/UsaComboBox.stories.js b/src/components/UsaComboBox/UsaComboBox.stories.js new file mode 100644 index 00000000..5cd659ec --- /dev/null +++ b/src/components/UsaComboBox/UsaComboBox.stories.js @@ -0,0 +1,242 @@ +import UsaComboBox from './UsaComboBox.vue' +import { ref } from 'vue' +import { testData } from '@/components/UsaComboBox/UsaComboBox.fixtures.js' + +const defaultProps = { + options: UsaComboBox.props.options.default(), + modelValue: UsaComboBox.props.modelValue.default, + label: UsaComboBox.props.label.default, + required: UsaComboBox.props.required.default, + disabled: UsaComboBox.props.disabled.default, + error: UsaComboBox.props.error.default, + id: UsaComboBox.props.id.default, + clearButtonAriaLabel: UsaComboBox.props.clearButtonAriaLabel.default, + toggleButtonAriaLabel: UsaComboBox.props.toggleButtonAriaLabel.default, + customClasses: UsaComboBox.props.customClasses.default(), +} + +export default { + component: UsaComboBox, + title: 'Components/UsaComboBox', + argTypes: { + options: { + control: { type: 'object' }, + }, + modelValue: { + control: { type: 'text' }, + }, + label: { + control: { type: 'text' }, + }, + required: { + control: { type: 'boolean' }, + }, + disabled: { + control: { type: 'boolean' }, + }, + error: { + control: { type: 'boolean' }, + }, + id: { + control: { type: 'text' }, + }, + clearButtonAriaLabel: { + control: { type: 'text' }, + }, + toggleButtonAriaLabel: { + control: { type: 'text' }, + }, + customClasses: { + control: { type: 'object' }, + }, + labelSlot: { + control: { type: 'text' }, + }, + hintSlot: { + control: { type: 'text' }, + }, + errorMessageSlot: { + control: { type: 'text' }, + }, + noResultsSlot: { + control: { type: 'text' }, + }, + assistiveHintSlot: { + control: { type: 'text' }, + }, + }, + args: { + options: defaultProps.options, + modelValue: defaultProps.modelValue, + label: defaultProps.label, + required: defaultProps.required, + disabled: defaultProps.disabled, + error: defaultProps.error, + id: defaultProps.id, + clearButtonAriaLabel: defaultProps.clearButtonAriaLabel, + toggleButtonAriaLabel: defaultProps.toggleButtonAriaLabel, + customClasses: defaultProps.customClasses, + labelSlot: '', + hintSlot: '', + errorMessageSlot: '', + noResultsSlot: '', + assistiveHintSlot: '', + }, +} + +const DefaultTemplate = (args, { argTypes }) => ({ + components: { UsaComboBox }, + props: Object.keys(argTypes), + setup() { + const modelValue = ref(args.modelValue) + return { ...args, modelValue } + }, + template: ` + + + + + + `, +}) + +export const DefaultComboBox = DefaultTemplate.bind({}) +DefaultComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, +} +DefaultComboBox.storyName = 'Default' + +export const DefaultValueComboBox = DefaultTemplate.bind({}) +DefaultValueComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + modelValue: 'raspberry', +} +DefaultValueComboBox.storyName = 'Default Value' + +export const HintComboBox = DefaultTemplate.bind({}) +HintComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + hintSlot: 'Choose wisely', +} +HintComboBox.storyName = 'Hint' + +export const ErrorComboBox = DefaultTemplate.bind({}) +ErrorComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + error: true, +} +ErrorComboBox.storyName = 'Error' + +export const ErrorMessageComboBox = DefaultTemplate.bind({}) +ErrorMessageComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + error: true, + errorMessageSlot: 'Error message here', +} +ErrorMessageComboBox.storyName = 'Error Message' + +export const RequiredComboBox = DefaultTemplate.bind({}) +RequiredComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + required: true, +} +RequiredComboBox.storyName = 'Required' + +export const CustomIdComboBox = DefaultTemplate.bind({}) +CustomIdComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + id: 'custom-id', +} +CustomIdComboBox.storyName = 'Custom ID' + +export const ClearButtonAriaLabelComboBox = DefaultTemplate.bind({}) +ClearButtonAriaLabelComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + modelValue: 'pomegranate', + clearButtonAriaLabel: 'Custom clear aria label', +} +ClearButtonAriaLabelComboBox.storyName = 'Custom Clear Button Aria Label' + +export const ToggleButtonAriaLabelComboBox = DefaultTemplate.bind({}) +ToggleButtonAriaLabelComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + toggleButtonAriaLabel: 'Custom toggle aria label', +} +ToggleButtonAriaLabelComboBox.storyName = 'Custom Toggle Button Aria Label' + +export const LabelSlotComboBox = DefaultTemplate.bind({}) +LabelSlotComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + labelSlot: `Label slot content`, +} +LabelSlotComboBox.storyName = 'Label Slot' + +export const NoResultsSlotComboBox = DefaultTemplate.bind({}) +NoResultsSlotComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + noResultsSlot: `Sorry, didn't find that.`, +} +NoResultsSlotComboBox.storyName = 'No Results Slot' + +export const AssistiveHintSlotComboBox = DefaultTemplate.bind({}) +AssistiveHintSlotComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + assistiveHintSlot: `Some custom hint text for screenreaders.`, +} +AssistiveHintSlotComboBox.storyName = 'Assistive Hint Slot' + +export const CustomClassesComboBox = DefaultTemplate.bind({}) +CustomClassesComboBox.args = { + ...defaultProps, + label: 'Fruit', + options: testData, + hintSlot: 'Choose wisely', + customClasses: { + component: ['test-component-class'], + label: ['test-label-class'], + input: ['test-input-class'], + list: ['test-list-class'], + }, +} +CustomClassesComboBox.storyName = 'Custom CSS Classes' diff --git a/src/components/UsaComboBox/UsaComboBox.test.js b/src/components/UsaComboBox/UsaComboBox.test.js new file mode 100644 index 00000000..814cfb18 --- /dev/null +++ b/src/components/UsaComboBox/UsaComboBox.test.js @@ -0,0 +1,1260 @@ +import '@module/uswds/dist/css/uswds.min.css' +import { mount } from '@cypress/vue' +import { + testData, + falsyTestData, +} from '@/components/UsaComboBox/UsaComboBox.fixtures.js' +import UsaComboBox from './UsaComboBox.vue' + +describe('UsaComboBox', () => { + it('renders the component', () => { + const wrapperComponent = { + components: { UsaComboBox }, + data() { + return { + options: testData, + selectedOption: '', + } + }, + template: ``, + } + + mount(wrapperComponent, {}) + + cy.get('label') + .should('have.class', 'usa-label') + .and('have.attr', 'for', 'vuswds-id-global-usa-combo-box-1') + .and('have.id', 'vuswds-id-global-usa-combo-box-1-label') + .and('contain', 'ComboBox') + + cy.get('div.usa-combo-box') + .as('comboBox') + .should('not.have.class', 'usa-combo-box--pristine') + .and('have.data', 'enhanced', true) + + cy.get('.usa-combo-box > input.usa-combo-box__input') + .as('input') + .should('have.id', 'vuswds-id-global-usa-combo-box-1') + .and('have.attr', 'aria-autocomplete', 'list') + .and('have.attr', 'aria-owns', 'vuswds-id-global-usa-combo-box-1-list') + .and( + 'have.attr', + 'aria-controls', + 'vuswds-id-global-usa-combo-box-1-list' + ) + .and( + 'have.attr', + 'aria-describedby', + 'vuswds-id-global-usa-combo-box-1-assistive-hint' + ) + .and('have.attr', 'aria-expanded', 'false') + .and('have.attr', 'autocapitalize', 'off') + .and('have.attr', 'autocomplete', 'off') + .and('have.attr', 'type', 'text') + .and('have.attr', 'role', 'combobox') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('span.usa-combo-box__clear-input__wrapper').should( + 'have.attr', + 'tabindex', + '-1' + ) + + cy.get( + '.usa-combo-box__clear-input__wrapper > button.usa-combo-box__clear-input' + ) + .as('clearButton') + .should('have.attr', 'type', 'button') + .and('have.attr', 'aria-label', 'Clear the select contents') + .and('be.hidden') + .and('contain', '\u00a0') + + cy.get('span.usa-combo-box__input-button-separator').should( + 'contain', + '\u00a0' + ) + + cy.get('span.usa-combo-box__toggle-list__wrapper').should( + 'have.attr', + 'tabindex', + '-1' + ) + + cy.get( + '.usa-combo-box__toggle-list__wrapper > button.usa-combo-box__toggle-list' + ) + .as('toggleButton') + .should('have.attr', 'type', 'button') + .and('have.attr', 'tabindex', '-1') + .and('have.attr', 'aria-label', 'Toggle the dropdown list') + .and('contain', '\u00a0') + + cy.get('.usa-combo-box__toggle-list__wrapper + ul.usa-combo-box__list') + .as('list') + .should('have.id', 'vuswds-id-global-usa-combo-box-1-list') + .and('have.attr', 'tabindex', '-1') + .and('have.attr', 'role', 'listbox') + .and( + 'have.attr', + 'aria-labelledby', + 'vuswds-id-global-usa-combo-box-1-label' + ) + .and('have.attr', 'hidden') + + cy.get('@list').children().should('have.length', 64) + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option') + .should('have.attr', 'aria-setsize', '64') + .and('have.attr', 'aria-selected', 'false') + .and('have.attr', 'role', 'option') + .and('have.attr', 'tabindex', '-1') + .and('not.have.class', 'usa-combo-box__list-option--selected') + .and('not.have.class', 'usa-combo-box__list-option--focused') + + cy.get('li.usa-combo-box__list-option--no-results').should('not.exist') + + testData.forEach((option, index) => { + cy.get(`li.usa-combo-box__list-option:nth-of-type(${index + 1})`) + .should( + 'have.id', + `vuswds-id-global-usa-combo-box-1-list-option-${index}` + ) + .and('have.attr', 'aria-posinset', index + 1) + .and('have.attr', 'data-value', option.value) + .and('contain', option.label) + }) + + cy.get('div.usa-combo-box__status') + .as('status') + .should('have.attr', 'role', 'status') + .and('have.class', 'usa-sr-only') + .and('be.empty') + + cy.get('span[id="vuswds-id-global-usa-combo-box-1-assistive-hint"]') + .should('have.class', 'usa-sr-only') + .and( + 'contain', + '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.' + ) + + cy.get('@input').click() + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:first-child' + ).should('have.class', 'usa-combo-box__list-option--focused') + + cy.get('@status').should('contain', `${testData.length} results available.`) + + // Click outside. + cy.get('html').click('topLeft') + + cy.get('@input') + .should('not.have.focus') + .and('have.attr', 'aria-expanded', 'false') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + + cy.get('@status').should('be.empty') + + cy.get('@input').click() + + cy.get('@input').should('have.focus') + + // Open again. + cy.get('@input').click() + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:first-child' + ).should('have.class', 'usa-combo-box__list-option--focused') + + cy.get('@status').should('contain', `${testData.length} results available.`) + + // Close with escape key. + cy.realPress('Escape') + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'false') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + + cy.get('@status').should('be.empty') + + // Open with down arrow. + cy.get('@input').type('{downArrow}') + + cy.get('@input') + .should('have.attr', 'aria-expanded', 'true') + .and( + 'have.attr', + 'aria-activedescendant', + 'vuswds-id-global-usa-combo-box-1-list-option-0' + ) + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(1)') + .should('have.focus') + .and('have.class', 'usa-combo-box__list-option--focused') + + // Close with up arrow. + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(1)') + .type('{upArrow}') + .should('not.have.focus') + .and('not.have.class', 'usa-combo-box__list-option--focused') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'false') + + // Open again. + cy.get('@input').click() + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(1)' + ).should('have.class', 'usa-combo-box__list-option--focused') + + cy.get('@status').should('contain', `${testData.length} results available.`) + + // Can't close with up arrow. + cy.get('@input').type('{upArrow}') + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(1)' + ).should('have.class', 'usa-combo-box__list-option--focused') + + // Open again. + cy.get('@input').click() + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(1)' + ).should('have.class', 'usa-combo-box__list-option--focused') + + cy.get('@status').should('contain', `${testData.length} results available.`) + }) + + it('highlight option on hover and arrow keys', () => { + const wrapperComponent = { + components: { UsaComboBox }, + data() { + return { + options: testData, + selectedOption: '', + id: 'arrow-key', + } + }, + template: ``, + } + + mount(wrapperComponent, {}) + + cy.get('.usa-combo-box') + .as('comboBox') + .should('not.have.class', 'usa-combo-box--pristine') + + cy.get('input') + .as('input') + .should('not.have.focus') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('ul').as('list').should('be.hidden').and('have.attr', 'hidden') + cy.get('.usa-combo-box__clear-input').as('clearButton').should('be.hidden') + cy.get('.usa-combo-box__toggle-list').as('toggleButton').should('exist') + cy.get('.usa-combo-box__status').as('status').should('be.empty') + + // Highlight first option. + cy.get('@input').type('{downArrow}') + + cy.get('@input') + .should('not.have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('have.attr', 'aria-activedescendant', 'arrow-key-list-option-0') + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(1)') + .should('have.class', 'usa-combo-box__list-option--focused') + .and('have.focus') + + // Highlight second option. + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(1)' + ).type('{downArrow}') + + cy.get('@input').should( + 'have.attr', + 'aria-activedescendant', + 'arrow-key-list-option-1' + ) + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(1)') + .should('not.have.focus') + .and('not.have.class', 'usa-combo-box__list-option--focused') + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(2)') + .should('have.class', 'usa-combo-box__list-option--focused') + .and('have.focus') + + // Highlight third option. + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(2)' + ).type('{downArrow}') + + cy.get('@input').should( + 'have.attr', + 'aria-activedescendant', + 'arrow-key-list-option-2' + ) + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(2)') + .should('not.have.focus') + .and('not.have.class', 'usa-combo-box__list-option--focused') + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(3)') + .should('have.class', 'usa-combo-box__list-option--focused') + .and('have.focus') + + // Highlight second option again. + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(3)') + .type('{upArrow}') + .should('not.have.class', 'usa-combo-box__list-option--focused') + .and('not.have.focus') + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(2)') + .should('have.class', 'usa-combo-box__list-option--focused') + .and('have.focus') + + // Highlight third option with mouseover. + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(3)') + .trigger('mouseover') + .should('have.class', 'usa-combo-box__list-option--focused') + .and('have.focus') + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(2)') + .should('not.have.focus') + .and('not.have.class', 'usa-combo-box__list-option--focused') + + // Highlight last option with mouseover. + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(64)') + .trigger('mouseover') + .should('have.class', 'usa-combo-box__list-option--focused') + .and('have.focus') + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(3)') + .should('not.have.focus') + .and('not.have.class', 'usa-combo-box__list-option--focused') + + // Can't highlight past last item. + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(64)') + .type('{downArrow}') + .should('have.focus') + .should('have.class', 'usa-combo-box__list-option--focused') + }) + + it('select option with tab key', () => { + const wrapperComponent = { + components: { UsaComboBox }, + data() { + return { + options: testData, + selectedOption: '', + id: 'tab', + } + }, + template: ``, + } + + mount(wrapperComponent, {}) + + cy.get('.usa-combo-box') + .as('comboBox') + .should('not.have.class', 'usa-combo-box--pristine') + + cy.get('input') + .as('input') + .should('not.have.focus') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('ul').as('list').should('be.hidden').and('have.attr', 'hidden') + cy.get('.usa-combo-box__clear-input').as('clearButton').should('be.hidden') + cy.get('.usa-combo-box__toggle-list').as('toggleButton').should('exist') + cy.get('.usa-combo-box__status').as('status').should('be.empty') + + cy.get('@input').click() + + // Highlight last option with mouseover. + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(64)') + .trigger('mouseover') + .should('have.class', 'usa-combo-box__list-option--focused') + .and('have.focus') + + // Select option by pressing tab. + cy.realPress('Tab') + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(64)') + .should('not.have.focus') + .should('not.have.class', 'usa-combo-box__list-option--focused') + .should('have.class', 'usa-combo-box__list-option--selected') + .and('have.attr', 'aria-selected', 'true') + + cy.get('@comboBox').should('have.class', 'usa-combo-box--pristine') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + + cy.get('@input').should('have.focus').and('have.value', 'Yuzu') + + cy.get('@clearButton').should('be.visible') + + // Tab to clear button. + cy.realPress('Tab') + + cy.get('@input').should('not.have.focus').and('have.value', 'Yuzu') + + // Clear input. + cy.get('@clearButton').should('have.focus').click() + cy.get('@clearButton').should('not.have.focus') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + + cy.get('@input').should('have.focus').and('have.value', '') + + // Open with toggle button. + cy.get('@toggleButton').click() + cy.get('@toggleButton').should('not.have.focus') + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:first-child' + ).should('have.class', 'usa-combo-box__list-option--focused') + + cy.get('@status').should('contain', `${testData.length} results available.`) + + // Close with toggle button. + cy.get('@toggleButton').click() + cy.get('@toggleButton').should('not.have.focus') + + cy.get('@input') + .should('have.focus') + .and('have.value', '') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:first-child' + ).should('not.have.class', 'usa-combo-box__list-option--focused') + }) + + it('options are filtered by typing', () => { + const wrapperComponent = { + components: { UsaComboBox }, + data() { + return { + options: testData, + selectedOption: '', + id: 'filtered', + } + }, + template: ``, + } + + mount(wrapperComponent, {}) + + cy.get('.usa-combo-box') + .as('comboBox') + .should('not.have.class', 'usa-combo-box--pristine') + + cy.get('input') + .as('input') + .should('not.have.focus') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('ul').as('list').should('be.hidden').and('have.attr', 'hidden') + cy.get('.usa-combo-box__clear-input').as('clearButton').should('be.hidden') + cy.get('.usa-combo-box__toggle-list').as('toggleButton').should('exist') + cy.get('.usa-combo-box__status').as('status').should('be.empty') + + cy.get('@input').click().type('APPLE') + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + cy.get('@list').children().should('have.length', 4) + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:first-child' + ).should('have.class', 'usa-combo-box__list-option--focused') + + cy.get('@status').should('contain', '4 results available.') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(1)' + ).should('contain', 'Apple') + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(2)' + ).should('contain', 'Crab apple') + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(3)' + ).should('contain', 'Custard apple') + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(4)' + ).should('contain', 'Pineapple') + }) + + it('can select option with mouse click', () => { + const wrapperComponent = { + components: { UsaComboBox }, + data() { + return { + options: testData, + selectedOption: '', + id: 'mouse-click', + } + }, + template: ``, + } + + mount(wrapperComponent, {}) + + cy.get('.usa-combo-box') + .as('comboBox') + .should('not.have.class', 'usa-combo-box--pristine') + + cy.get('input') + .as('input') + .should('not.have.focus') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('ul').as('list').should('be.hidden').and('have.attr', 'hidden') + cy.get('.usa-combo-box__clear-input').as('clearButton').should('be.hidden') + cy.get('.usa-combo-box__toggle-list').as('toggleButton').should('exist') + cy.get('.usa-combo-box__status').as('status').should('be.empty') + + cy.get('@input') + .type('apple') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + cy.get('@list').children().should('have.length', 4) + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:first-child' + ).should('have.class', 'usa-combo-box__list-option--focused') + + cy.get('@status').should('contain', '4 results available.') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(1)' + ).should('contain', 'Apple') + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(2)' + ).should('contain', 'Crab apple') + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(3)' + ).should('contain', 'Custard apple') + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(4)' + ).should('contain', 'Pineapple') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:nth-child(3)' + ).click() + + cy.get('@input') + .should('have.focus') + .and('have.value', 'Custard apple') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@comboBox').should('have.class', 'usa-combo-box--pristine') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option').should( + 'not.have.class', + 'usa-combo-box__list-option--focused' + ) + + cy.get('@input').type(' ').should('have.value', 'Custard apple ') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + cy.get('@list').children().should('have.length', 1) + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option--no-results') + .should('contain', 'No results found') + .and('not.have.class', 'usa-combo-box__list-option--focused') + + cy.get('@status').should('contain', 'No results.') + + // Remove space. + cy.get('@input').type('{backspace}').should('have.value', 'Custard apple') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + cy.get('@list').children().should('have.length', 1) + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option:first-child' + ).should('have.class', 'usa-combo-box__list-option--focused') + + cy.get('@status').should('contain', '1 result available.') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option[data-value="custard apple"]' + ) + .should('not.have.focus') + .and('have.class', 'usa-combo-box__list-option--focused') + .and('have.class', 'usa-combo-box__list-option--selected') + .and('have.attr', 'aria-selected', 'true') + + cy.get('@toggleButton').click() + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + + cy.get('@toggleButton').click() + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('have.attr', 'aria-activedescendant', 'mouse-click-list-option-15') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option[data-value="custard apple"]' + ) + .should('not.have.focus') + .and('have.class', 'usa-combo-box__list-option--focused') + .and('have.class', 'usa-combo-box__list-option--selected') + .and('have.attr', 'aria-selected', 'true') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option[data-value="cherry"]' + ).trigger('mouseover') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option[data-value="custard apple"]' + ) + .should('not.have.focus') + .and('not.have.class', 'usa-combo-box__list-option--focused') + .and('have.attr', 'aria-selected', 'true') + }) + + it('can tab to selected option', () => { + const wrapperComponent = { + components: { UsaComboBox }, + data() { + return { + options: testData, + selectedOption: 'strawberry', + id: 'tab-selected', + } + }, + template: ``, + } + + mount(wrapperComponent, {}) + + cy.get('.usa-combo-box') + .as('comboBox') + .should('have.class', 'usa-combo-box--pristine') + + cy.get('input') + .as('input') + .should('not.have.focus') + .and('have.value', 'Strawberry') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('ul').as('list').should('be.hidden').and('have.attr', 'hidden') + cy.get('.usa-combo-box__clear-input').as('clearButton').should('be.visible') + cy.get('.usa-combo-box__toggle-list').as('toggleButton').should('exist') + cy.get('.usa-combo-box__status').as('status').should('be.empty') + + cy.get('@toggleButton').click() + cy.get('@input').should('have.focus') + + cy.realPress('Tab') + cy.get('@clearButton').should('have.focus') + + cy.realPress('Tab') + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option[data-value="strawberry"]' + ) + .should('be.visible') + .and('have.focus') + }) + + it('can select option with enter key', () => { + const wrapperComponent = { + components: { UsaComboBox }, + data() { + return { + options: testData, + selectedOption: '', + id: 'enter-key', + } + }, + template: ``, + } + + mount(wrapperComponent, {}) + + cy.get('.usa-combo-box') + .as('comboBox') + .should('not.have.class', 'usa-combo-box--pristine') + + cy.get('input') + .as('input') + .should('not.have.focus') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('ul').as('list').should('be.hidden').and('have.attr', 'hidden') + cy.get('.usa-combo-box__clear-input').as('clearButton').should('be.hidden') + cy.get('.usa-combo-box__toggle-list').as('toggleButton').should('exist') + cy.get('.usa-combo-box__status').as('status').should('be.empty') + + cy.get('@input').click() + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + cy.get('@clearButton').should('be.hidden') + cy.get('@status').should('contain', '64 results available.') + + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option[data-value="cherry"]' + ) + .as('cherryOption') + .trigger('mouseover') + + cy.get('@cherryOption').should('be.visible') + + cy.get('@input').should( + 'have.attr', + 'aria-activedescendant', + 'enter-key-list-option-14' + ) + + cy.get('@comboBox').should('not.have.class', 'usa-combo-box--pristine') + + cy.get('@cherryOption') + .should('have.focus') + .and('have.class', 'usa-combo-box__list-option--focused') + .and('not.have.class', 'usa-combo-box__list-option--selected') + .and('have.attr', 'aria-selected', 'false') + .type('{enter}') + + cy.get('@comboBox').should('have.class', 'usa-combo-box--pristine') + cy.get('@clearButton').should('be.visible') + + cy.get('@cherryOption') + .should('not.have.focus') + .and('not.have.class', 'usa-combo-box__list-option--focused') + .and('have.class', 'usa-combo-box__list-option--selected') + .and('have.attr', 'aria-selected', 'true') + .and('be.hidden') + + cy.get('@status').should('be.empty') + + cy.get('@input') + .should('have.focus') + .and('have.value', 'Cherry') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + + cy.get('@input').click() + + cy.get('@comboBox').should('have.class', 'usa-combo-box--pristine') + cy.get('@clearButton').should('be.visible') + cy.get('@status').should('contain', '64 results available.') + + cy.get('@input') + .should('have.focus') + .and('have.attr', 'aria-expanded', 'true') + .and('have.attr', 'aria-activedescendant', 'enter-key-list-option-14') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + + cy.get('@cherryOption') + .should('not.have.focus') + .and('have.class', 'usa-combo-box__list-option--focused') + .and('have.class', 'usa-combo-box__list-option--selected') + .and('have.attr', 'aria-selected', 'true') + .and('be.visible') + + cy.get('@toggleButton').click() + + cy.get('@input') + .should('have.focus') + .and('have.value', 'Cherry') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + cy.get('@status').should('be.empty') + + cy.get('@cherryOption') + .should('not.have.focus') + .and('not.have.class', 'usa-combo-box__list-option--focused') + .and('have.class', 'usa-combo-box__list-option--selected') + .and('have.attr', 'aria-selected', 'true') + .and('be.hidden') + + cy.get('@input').type('{downArrow}') + + cy.get('@input') + .should('have.attr', 'aria-expanded', 'true') + .and('have.attr', 'aria-activedescendant', 'enter-key-list-option-14') + + cy.get('@cherryOption') + .should('have.focus') + .and('have.class', 'usa-combo-box__list-option--focused') + .and('have.class', 'usa-combo-box__list-option--selected') + .and('have.attr', 'aria-selected', 'true') + .and('be.visible') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + cy.get('@status').should('contain', '64 results available.') + + cy.get('@input').type(' ').type('{downArrow}') + + cy.get('@input') + .should('have.focus') + .and('have.value', 'Cherry ') + .and('have.attr', 'aria-expanded', 'true') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.visible').and('not.have.attr', 'hidden') + cy.get('@list').children().should('have.length', 1) + + cy.get('@cherryOption').should('not.exist') + + cy.get('.usa-combo-box__list > li.usa-combo-box__list-option--no-results') + .should('contain', 'No results found') + .and('not.have.class', 'usa-combo-box__list-option--focused') + + cy.get('@status').should('contain', 'No results.') + + cy.get('@input').type('{backspace}') + + cy.get('@status').should('contain', '1 result available.') + + cy.get('@input').type('{enter}') + + cy.get('@input') + .should('have.focus') + .and('have.value', 'Cherry') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + + cy.get('@cherryOption') + .should('not.have.focus') + .and('not.have.class', 'usa-combo-box__list-option--focused') + .and('have.class', 'usa-combo-box__list-option--selected') + .and('have.attr', 'aria-selected', 'true') + + cy.get('@clearButton').click() + + cy.get('@input').type('Lime') + + cy.get('@status').should('contain', '1 result available.') + + cy.get('@input').type('{enter}') + + cy.get('@status').should('be.empty') + + cy.get('@input') + .should('have.focus') + .and('have.value', 'Lime') + .and('have.attr', 'aria-expanded', 'false') + .and('not.have.attr', 'aria-activedescendant') + + cy.get('@list').should('be.hidden').and('have.attr', 'hidden') + }) + + it('clear and toggle buttons use custom aria-label attribute values', () => { + mount(UsaComboBox, { + props: { + options: testData, + clearButtonAriaLabel: 'Test clear button aria label', + toggleButtonAriaLabel: 'Test toggle button aria label', + }, + }) + + cy.get( + '.usa-combo-box__clear-input__wrapper > button.usa-combo-box__clear-input' + ).should('have.attr', 'aria-label', 'Test clear button aria label') + + cy.get( + '.usa-combo-box__toggle-list__wrapper > button.usa-combo-box__toggle-list' + ).should('have.attr', 'aria-label', 'Test toggle button aria label') + }) + + it('uses custom id prop value to generate element ids', () => { + mount(UsaComboBox, { + props: { + options: falsyTestData, + id: 'test-combo-box-id', + label: 'Test combo box label', + }, + slots: { + label: () => 'Test label slot', + hint: () => 'Test hint slot', + 'error-message': () => 'Test error slot', + }, + }).as('wrapper') + + cy.get('.usa-form-group').should('exist') + + cy.get('label') + .should('have.id', 'test-combo-box-id-label') + .and('contain', 'Test label slot') + + cy.get('.usa-hint').should('have.id', 'test-combo-box-id-hint') + + cy.get('.usa-error-message').should('not.exist') + + cy.get('input').should('have.id', 'test-combo-box-id') + + cy.get('ul').should('have.id', 'test-combo-box-id-list') + + cy.get('li:nth-of-type(1)').should( + 'have.id', + 'test-combo-box-id-list-option-0' + ) + cy.get('li:nth-of-type(2)').should( + 'have.id', + 'test-combo-box-id-list-option-1' + ) + cy.get('li:nth-of-type(3)').should( + 'have.id', + 'test-combo-box-id-list-option-2' + ) + cy.get('li:nth-of-type(4)').should( + 'have.id', + 'test-combo-box-id-list-option-3' + ) + cy.get('li:nth-of-type(5)').should( + 'have.id', + 'test-combo-box-id-list-option-4' + ) + cy.get('li:nth-of-type(6)').should( + 'have.id', + 'test-combo-box-id-list-option-5' + ) + + cy.get('.usa-combo-box__status + span.usa-sr-only').should( + 'have.id', + 'test-combo-box-id-assistive-hint' + ) + + cy.get('@wrapper').invoke('setProps', { error: true }) + + cy.get('.usa-error-message').should( + 'have.id', + 'test-combo-box-id-error-message' + ) + }) + + it('displays error message even if no hint set', () => { + mount(UsaComboBox, { + props: { + options: testData, + error: true, + }, + slots: { + 'error-message': () => 'Test error slot', + }, + }).as('wrapper') + + cy.get('.usa-form-group').should('exist') + cy.get('label').should('not.exist') + + cy.get('.usa-hint').should('not.exist') + + cy.get('.usa-error-message') + .should('contain', 'Test error slot') + .and('be.visible') + + cy.get('@wrapper').invoke('setProps', { error: false }) + + cy.get('.usa-form-group').should('not.exist') + + cy.get('.usa-error-message').should('not.exist') + }) + + it('`disabled` prop makes component non-interactive', () => { + mount(UsaComboBox, { + props: { + label: 'Disabled ComboBox', + options: testData, + disabled: true, + }, + }) + + cy.get('input').should('not.have.focus').click({ force: true }) + cy.get('input').should('not.have.focus') + + cy.get('.usa-combo-box__clear-input') + .should('not.have.focus') + .click({ force: true }) + cy.get('.usa-combo-box__clear-input').should('not.have.focus') + + cy.get('.usa-combo-box__toggle-list') + .should('not.have.focus') + .click({ force: true }) + cy.get('.usa-combo-box__toggle-list').should('not.have.focus') + }) + + it('uses custom CSS classes', () => { + mount(UsaComboBox, { + props: { + label: 'Custom ComboBox', + options: testData, + customClasses: { + component: ['test-component-class'], + label: ['test-label-class'], + input: ['test-input-class'], + list: ['test-list-class'], + }, + }, + attrs: { + 'data-test': 'test-attribute', + }, + slots: { + hint: () => 'Test hint', + }, + }) + + cy.get('.usa-form-group') + .should('have.class', 'test-component-class') + .and('not.have.attr', 'data-test') + + cy.get('label').should('have.class', 'test-label-class') + + cy.get('input') + .should('have.class', 'test-input-class') + .and('have.attr', 'data-test', 'test-attribute') + + cy.get('ul').should('have.class', 'test-list-class') + }) + + it('custom empty and assistive hint slot text', () => { + mount(UsaComboBox, { + slots: { + 'no-results': () => 'Test no results text', + 'assistive-hint': () => 'Test assistive hint', + }, + }) + + cy.get('.usa-combo-box__list-option--no-results').should( + 'contain', + 'Test no results text' + ) + + cy.get('.usa-combo-box__status + span.usa-sr-only').should( + 'contain', + 'Test assistive hint' + ) + }) + + it('uses status scoped slot content', () => { + mount(UsaComboBox, { + props: { + options: testData, + }, + slots: { + status: ({ filteredOptions }) => `total: ${filteredOptions.length}`, + }, + }) + + cy.get('.usa-combo-box__status').as('status').should('be.empty') + + cy.get('input').as('input').click() + + cy.get('@input').should('have.focus') + + cy.get('ul').should('be.visible') + + cy.get('@status').should('contain', 'total: 64') + + cy.get('@input').type('Apple') + + cy.get('@status').should('contain', 'total: 4') + + cy.get('@input').clear().type('Pineapple') + + cy.get('@status').should('contain', 'total: 1') + + cy.get('@input').type('2') + + cy.get('@status').should('contain', 'total: 0') + + cy.get('@input').type('{backspace}') + + cy.get('@status').should('contain', 'total: 1') + }) + + it('shows required field indicators', () => { + mount(UsaComboBox, { + props: { + label: 'Is Required', + options: testData, + required: true, + }, + }) + + cy.get('label abbr') + .should('have.attr', 'title', 'required') + .and('contain', '*') + + cy.get('input').should('have.attr', 'required') + }) + + it('adds correct aria-describedby ids', () => { + mount(UsaComboBox, { + props: { + label: 'aria-describedby', + options: testData, + id: 'custom-test-id', + }, + slots: { + hint: () => 'Test hint', + 'error-message': () => 'Test error message', + }, + }).as('wrapper') + + cy.get('input') + .as('input') + .should( + 'have.attr', + 'aria-describedby', + 'custom-test-id-assistive-hint custom-test-id-hint' + ) + + cy.get('@wrapper').invoke('setProps', { error: true }) + + cy.get('input') + .as('input') + .should( + 'have.attr', + 'aria-describedby', + 'custom-test-id-assistive-hint custom-test-id-hint custom-test-id-error-message' + ) + }) + + it('starts with default value', () => { + const wrapperComponent = { + components: { UsaComboBox }, + data() { + return { + options: testData, + selectedOption: 'nectarine', + id: 'default-value', + } + }, + template: ``, + } + + mount(wrapperComponent, {}) + + cy.get('input').should('have.value', 'Nectarine') + cy.get( + '.usa-combo-box__list > li.usa-combo-box__list-option[data-value="nectarine"]' + ) + .should('have.class', 'usa-combo-box__list-option--selected') + .and('have.attr', 'aria-selected', 'true') + }) + + it('component emits v-model event', () => { + mount(UsaComboBox, { + props: { + label: 'Emitted', + options: testData, + }, + }).as('wrapper') + + cy.get('@wrapper') + .vue() + .then(vm => { + expect(vm.emitted()).to.not.have.property('update:modalValue') + }) + + cy.get('input').click() + + cy.get('li:first-child').click() + + cy.get('@wrapper') + .vue() + .then(vm => { + expect(vm.emitted()).to.have.property('update:modelValue') + const currentCheckedEvent = vm.emitted('update:modelValue') + expect(currentCheckedEvent).to.have.length(1) + expect(currentCheckedEvent[currentCheckedEvent.length - 1]).to.contain( + 'apple' + ) + }) + }) +}) diff --git a/src/components/UsaComboBox/UsaComboBox.vue b/src/components/UsaComboBox/UsaComboBox.vue new file mode 100644 index 00000000..d63314f1 --- /dev/null +++ b/src/components/UsaComboBox/UsaComboBox.vue @@ -0,0 +1,273 @@ + + + + + diff --git a/src/components/UsaComboBox/index.js b/src/components/UsaComboBox/index.js new file mode 100644 index 00000000..103c5014 --- /dev/null +++ b/src/components/UsaComboBox/index.js @@ -0,0 +1,4 @@ +import UsaComboBox from './UsaComboBox.vue' + +export { UsaComboBox } +export default UsaComboBox diff --git a/src/components/index.js b/src/components/index.js index 88ad8a00..31c48fda 100644 --- a/src/components/index.js +++ b/src/components/index.js @@ -21,10 +21,15 @@ export { default as UsaCollectionHeading } from './UsaCollectionHeading' export { default as UsaCollectionItem } from './UsaCollectionItem' export { default as UsaCollectionMeta } from './UsaCollectionMeta' export { default as UsaCollectionMetaItem } from './UsaCollectionMetaItem' +export { default as UsaComboBox } from './UsaComboBox' export { default as UsaDateInput } from './UsaDateInput' export { default as UsaDropdown } from './UsaDropdown' export { default as UsaFooter } from './UsaFooter' +export { default as UsaFooterCollapsibleMenu } from './UsaFooterCollapsibleMenu' +export { default as UsaFooterCollapsibleMenuSection } from './UsaFooterCollapsibleMenuSection' export { default as UsaFooterLogo } from './UsaFooterLogo' +export { default as UsaFooterMenu } from './UsaFooterMenu' +export { default as UsaFooterNav } from './UsaFooterNav' export { default as UsaFooterPrimarySection } from './UsaFooterPrimarySection' export { default as UsaFooterSecondarySection } from './UsaFooterSecondarySection' export { default as UsaFooterSocialLinks } from './UsaFooterSocialLinks' @@ -34,6 +39,9 @@ export { default as UsaGraphicListRow } from './UsaGraphicListRow' export { default as UsaHeader } from './UsaHeader' export { default as UsaHero } from './UsaHero' export { default as UsaHeroCallout } from './UsaHeroCallout' +export { default as UsaIcon } from './UsaIcon' +export { default as UsaIconList } from './UsaIconList' +export { default as UsaIconListItem } from './UsaIconListItem' export { default as UsaIdentiferMoreInfo } from './UsaIdentiferMoreInfo' export { default as UsaIdentifier } from './UsaIdentifier' export { default as UsaIdentifierLogo } from './UsaIdentifierLogo' @@ -46,7 +54,6 @@ export { default as UsaMediaBlock } from './UsaMediaBlock' export { default as UsaModal } from './UsaModal' export { default as UsaModalCloseButton } from './UsaModalCloseButton' export { default as UsaNav } from './UsaNav' -export { default as UsaNavbar } from './UsaNavbar' export { default as UsaNavDropdown } from './UsaNavDropdown' export { default as UsaNavDropdownButton } from './UsaNavDropdownButton' export { default as UsaNavPrimary } from './UsaNavPrimary' @@ -54,6 +61,7 @@ export { default as UsaNavPrimaryItem } from './UsaNavPrimaryItem' export { default as UsaNavSecondary } from './UsaNavSecondary' export { default as UsaNavSubmenu } from './UsaNavSubmenu' export { default as UsaNavSubmenuItem } from './UsaNavSubmenuItem' +export { default as UsaNavbar } from './UsaNavbar' export { default as UsaOverlay } from './UsaOverlay' export { default as UsaPagination } from './UsaPagination' export { default as UsaPaginationArrow } from './UsaPaginationArrow' @@ -76,14 +84,7 @@ export { default as UsaTable } from './UsaTable' export { default as UsaTableHeaderCell } from './UsaTableHeaderCell' export { default as UsaTableSortButton } from './UsaTableSortButton' export { default as UsaTag } from './UsaTag' -export { default as UsaTextarea } from './UsaTextarea' export { default as UsaTextInput } from './UsaTextInput' -export { default as UsaValidation } from './UsaValidation' -export { default as UsaIcon } from './UsaIcon' -export { default as UsaIconListItem } from './UsaIconListItem' -export { default as UsaIconList } from './UsaIconList' -export { default as UsaFooterNav } from './UsaFooterNav' -export { default as UsaFooterMenu } from './UsaFooterMenu' -export { default as UsaFooterCollapsibleMenu } from './UsaFooterCollapsibleMenu' -export { default as UsaFooterCollapsibleMenuSection } from './UsaFooterCollapsibleMenuSection' +export { default as UsaTextarea } from './UsaTextarea' export { default as UsaTooltip } from './UsaTooltip' +export { default as UsaValidation } from './UsaValidation' diff --git a/src/composables/useComboBox.js b/src/composables/useComboBox.js new file mode 100644 index 00000000..c2a93845 --- /dev/null +++ b/src/composables/useComboBox.js @@ -0,0 +1,449 @@ +import { ref, computed, readonly, watch, nextTick } from 'vue' +import { onKeyStroke, onClickOutside, useActiveElement } from '@vueuse/core' +import { nextId } from '@/utils/unique-id.js' +import { escapeRegExp } from '@/utils/common.js' + +export default (_id, _selectedOption, _options, emit) => { + const id = ref(_id) + const selectedOption = computed({ + get() { + return ref(_selectedOption).value + }, + set(value) { + emit('update:modelValue', value) + }, + }) + + const activeElement = useActiveElement() + const isDirty = ref(false) + const searchTerm = ref('') + const highlightedOption = ref('') + const options = ref(_options) + const isOpen = ref(false) + + const selectedLabel = computed(() => { + if (selectedOption.value === '') { + return '' + } + + const foundOption = options.value.find( + option => option.value === selectedOption.value + ) + + return foundOption?.label || '' + }) + + // Set the default value. + if (selectedLabel.value) { + searchTerm.value = selectedLabel.value + } + + watch(searchTerm, currentTerm => { + if (isOpen.value && currentTerm !== '') { + isDirty.value = true + } + }) + + const filteredOptions = computed(() => { + if ( + searchTerm.value === '' || + (!isDirty.value && searchTerm.value === selectedLabel.value) + ) { + return options.value + } + + return options.value.filter(option => { + const regex = new RegExp(escapeRegExp(searchTerm.value), 'gi') + return regex.test(option.label) + }) + }) + + const totalFilteredOptions = computed(() => filteredOptions.value?.length) + + const computedId = computed(() => id.value || nextId('usa-combo-box')) + const computedLabelId = computed(() => `${computedId.value}-label`) + const computedErrorMessageId = computed( + () => `${computedId.value}-error-message` + ) + const computedHintId = computed(() => `${computedId.value}-hint`) + const computedAssistiveHintId = computed( + () => `${computedId.value}-assistive-hint` + ) + const computedListId = computed(() => `${computedId.value}-list`) + + const getListItemIdByIndex = index => + `${computedListId.value}-option-${index}` + + const componentElement = ref(null) + const inputElement = ref(null) + const listElement = ref(null) + const listItemElements = ref([]) + + const getItemRefById = id => { + return listItemElements.value.find(item => item.id === id) + } + + const getItemRefByValue = value => { + return listItemElements.value.find(item => item.dataset.value === value) + } + + const firstOptionValue = computed(() => { + return totalFilteredOptions.value ? filteredOptions.value[0].value : '' + }) + + const firstOptionRef = computed(() => { + const firstItemId = totalFilteredOptions.value + ? listItemElements.value[0].id + : null + + return firstItemId ? getItemRefById(firstItemId) : null + }) + + const selectedOptionRef = computed(() => { + if (selectedOption.value === '') { + return null + } + + const foundItemRef = listItemElements.value.find( + itemRef => itemRef.dataset.value === selectedOption.value + ) + + return foundItemRef ? foundItemRef : null + }) + + const highlightedOptionRef = computed(() => { + if (highlightedOption.value === '') { + return null + } + + const foundItemRef = listItemElements.value.find(itemRef => { + return itemRef.dataset.value === highlightedOption.value + }) + + return foundItemRef ? foundItemRef : null + }) + + const isFirstOption = computed(() => { + const optionIndex = filteredOptions.value.findIndex( + item => item.value === highlightedOption.value + ) + + return optionIndex === 0 + }) + + const isLastOption = computed(() => { + const optionIndex = filteredOptions.value.findIndex( + item => item.value === highlightedOption.value + ) + + return optionIndex === totalFilteredOptions.value - 1 + }) + + const activeDescendent = computed(() => { + const activeOptionId = activeElement.value.id + + if (activeOptionId === highlightedOptionId.value) { + return highlightedOptionId.value + } + + if ( + totalFilteredOptions.value && + selectedOption.value !== '' && + isOpen.value && + activeOptionId === computedId.value + ) { + return highlightedOptionId.value + } + + return null + }) + + const scrollList = elementRef => { + if (!elementRef || !listElement?.value) { + return + } + + const optionBottom = elementRef.offsetTop + elementRef.offsetHeight + const currentBottom = + listElement.value.scrollTop + listElement.value.offsetHeight + + if (optionBottom > currentBottom) { + listElement.value.scrollTop = + optionBottom - listElement.value.offsetHeight + } + + if (elementRef.offsetTop < listElement.value.scrollTop) { + listElement.value.scrollTop = elementRef.offsetTop + } + } + + const focusInput = () => { + inputElement.value.focus() + } + + const selectOption = optionValue => { + selectedOption.value = optionValue + + const option = options.value.find(option => option.value === optionValue) + + searchTerm.value = option?.label || '' + + isDirty.value = false + } + + const clearSelectedOption = () => { + selectOption('') + } + + const highlightOption = optionValue => { + highlightedOption.value = optionValue + } + + const clearHighlightedOption = () => { + highlightedOption.value = '' + } + + const openList = () => { + isOpen.value = true + } + + const closeList = () => { + isOpen.value = false + isDirty.value = false + clearHighlightedOption() + listElement.value.scrollTop = 0 + } + + const listItemTabIndex = optionValue => { + return highlightedOption.value === optionValue || + selectedOption.value === optionValue + ? 0 + : -1 + } + + const highlightedOptionId = computed(() => { + if (!highlightedOption.value) { + return null + } + + const highlightedOptionIndex = filteredOptions.value.findIndex( + option => option.value === highlightedOption.value + ) + + return getListItemIdByIndex(highlightedOptionIndex) + }) + + const handleFilterOnInput = () => { + if (!isOpen.value) { + openList() + } + + if (selectedOption.value !== '') { + highlightOption(selectedOption.value) + } else { + highlightOption(firstOptionValue.value) + } + } + + const handleEnterOnInput = () => { + const foundItem = filteredOptions.value.find( + item => item.label === searchTerm.value + ) + + if (searchTerm.value !== '' && foundItem.value) { + selectOption(foundItem.value) + } + + closeList() + } + + const handleListToggle = () => { + if (isOpen.value) { + closeList() + clearHighlightedOption() + } else { + openList() + + if (selectedOption.value !== '') { + highlightOption(selectedOption.value) + + nextTick(() => { + scrollList(selectedOptionRef.value) + }) + } else { + highlightOption(firstOptionValue.value) + } + } + + focusInput() + } + + const handleClearInput = () => { + clearSelectedOption() + clearHighlightedOption() + focusInput() + } + + const handleHoverOnListOption = value => { + highlightOption(value) + + if (highlightedOptionRef.value) { + highlightedOptionRef.value.focus({ preventScroll: true }) + } + } + + const handleTabOnListOption = value => { + selectOption(value) + closeList() + focusInput() + } + + const handleEnterOnListOption = value => { + selectOption(value) + closeList() + focusInput() + } + + const handleDownOnListOption = index => { + if (!isLastOption.value) { + highlightOption(filteredOptions.value[index + 1].value) + + const itemRef = getItemRefByValue(highlightedOption.value) + + scrollList(itemRef.value) + + if (highlightedOptionRef.value) { + highlightedOptionRef.value.focus() + } + } + } + + const handleUpOnListOption = index => { + if (isFirstOption.value) { + closeList() + clearHighlightedOption() + focusInput() + } else { + highlightOption(filteredOptions.value[index - 1].value) + + const itemRef = getItemRefByValue(highlightedOption.value) + + scrollList(itemRef.value) + + if (highlightedOptionRef.value) { + highlightedOptionRef.value.focus() + } + } + } + + const handleClickOutside = () => { + if (isOpen.value) { + closeList() + } + selectOption(selectedOption.value) + clearHighlightedOption() + } + + const handleClickOnListOption = value => { + selectOption(value) + closeList() + focusInput() + } + + const handleEscape = () => { + closeList() + selectOption(selectedOption.value) + clearHighlightedOption() + focusInput() + } + + const handleDownOnInput = () => { + if (!isOpen.value) { + openList() + } + + if (!totalFilteredOptions.value) { + return + } + + if (selectedOption.value) { + highlightOption(selectedOption.value) + + nextTick(() => { + selectedOptionRef.value.focus() + + scrollList(selectedOptionRef.value) + }) + } else { + highlightOption(firstOptionValue.value) + + nextTick(() => { + highlightedOptionRef.value.focus() + + scrollList(highlightedOptionRef.value) + }) + } + } + + const handleClickOnInput = () => { + if (!isOpen.value) { + ;`` + openList() + } + + if (selectedOption.value) { + highlightOption(selectedOption.value) + + nextTick(() => { + scrollList(selectedOptionRef.value) + }) + } else if (highlightedOption.value === '') { + highlightOption(firstOptionValue.value) + + nextTick(() => { + scrollList(firstOptionRef.value) + }) + } + } + + onClickOutside(componentElement, () => handleClickOutside()) + + onKeyStroke('Escape', () => { + handleEscape() + }) + + return { + activeDescendent, + componentElement, + computedAssistiveHintId, + computedErrorMessageId, + computedHintId, + computedId, + computedLabelId, + computedListId, + filteredOptions, + getListItemIdByIndex, + handleClearInput, + handleClickOnInput, + handleClickOnListOption, + handleDownOnInput, + handleDownOnListOption, + handleEnterOnInput, + handleEnterOnListOption, + handleFilterOnInput, + handleHoverOnListOption, + handleListToggle, + handleTabOnListOption, + handleUpOnListOption, + highlightedOption: readonly(highlightedOption), + inputElement, + isOpen: readonly(isOpen), + listElement, + listItemElements, + listItemTabIndex, + searchTerm, + selectedLabel, + selectedOption, + totalFilteredOptions, + } +} diff --git a/src/utils/common.js b/src/utils/common.js index 816f0d0c..e7cb8d91 100644 --- a/src/utils/common.js +++ b/src/utils/common.js @@ -4,3 +4,6 @@ export const objectHasKey = (object, key) => Object.prototype.hasOwnProperty.call(object, key) export const kebabCase = value => justKebabCase(value) + +export const escapeRegExp = string => + string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')