diff --git a/packages/core/package.json b/packages/core/package.json index 7235fcbb00..f7831b38f3 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -12,7 +12,7 @@ "@cmsgov/design-system-support": "^1.32.1", "classnames": "^2.2.5", "core-js": "^2.5.3", - "downshift": "1.31.16", + "downshift": "^3.0.0", "ev-emitter": "^1.1.1", "lodash.uniqueid": "^4.0.1", "react-aria-modal": "^2.11.1" diff --git a/packages/core/src/components/Autocomplete/Autocomplete.e2e.test.js b/packages/core/src/components/Autocomplete/Autocomplete.e2e.test.js index 87d735e252..750e160b40 100644 --- a/packages/core/src/components/Autocomplete/Autocomplete.e2e.test.js +++ b/packages/core/src/components/Autocomplete/Autocomplete.e2e.test.js @@ -1,17 +1,145 @@ -/* global driver */ +/* global driver, by, key */ +import { + getElementByClassName, + getElementByXPath, + getFocusInnerText +} from '../../helpers/e2e'; import { ROOT_URL } from '../../helpers/e2e/constants'; import assertNoAxeViolations from '../../helpers/e2e/assertNoAxeViolations'; -import { getElementByClassName } from '../../helpers/e2e'; const rootURL = `${ROOT_URL}/example/components.autocomplete.react/`; -describe('Alert component', () => { +describe('Autocomplete component', () => { it('Should render', async() => { await driver.get(rootURL); - const el = await getElementByClassName('ds-u-clearfix ds-c-autocomplete'); - expect(el).toBeTruthy(); + const autocompleteField = await getElementByClassName( + 'ds-u-clearfix ds-c-autocomplete' + ); + expect(autocompleteField).toBeTruthy(); + }); + + it('Should expand the listbox when keys are pressed', async() => { + await driver.get(rootURL); + + const autocompleteField = await getElementByXPath( + '//*[@id="autocomplete_1"]' + ); + autocompleteField.click(); + await autocompleteField.sendKeys('c'); + + const listbox = await getElementByXPath( + '//*[@id="autocomplete_owned_container_4"]' + ); + expect(listbox).toBeTruthy(); + }); + + it('Should set the input value correctly when a listbox selection is clicked', async() => { + await driver.get(rootURL); + + let autocompleteField = await getElementByXPath( + '//*[@id="autocomplete_1"]' + ); + autocompleteField.click(); + await autocompleteField.sendKeys('c'); + + const listboxItem = await getElementByXPath( + '//*[@id="downshift-0-item-0"]' + ); + listboxItem.click(); + + autocompleteField = await getElementByXPath('//*[@id="autocomplete_1"]'); + autocompleteField = await autocompleteField.getAttribute('value'); + expect(autocompleteField).toEqual('Cook County, IL'); + }); + + it('Should set the input value to empty when Clear search is clicked', async() => { + await driver.get(rootURL); + + let autocompleteField = await getElementByXPath( + '//*[@id="autocomplete_1"]' + ); + autocompleteField.click(); + await autocompleteField.sendKeys('c'); + + const listboxItem = await getElementByXPath( + '//*[@id="downshift-0-item-0"]' + ); + listboxItem.click(); + + const clearButton = await getElementByXPath( + '//*[@id="js-example"]/div/div[1]/button' + ); + clearButton.click(); + + autocompleteField = await getElementByXPath('//*[@id="autocomplete_1"]'); + autocompleteField = await autocompleteField.getAttribute('value'); + expect(autocompleteField).toEqual(''); + }); + + it('Should select list items by keyboard', async() => { + await driver.get(rootURL); + + let autocompleteField = await getElementByXPath( + '//*[@id="autocomplete_1"]' + ); + autocompleteField.click(); + await autocompleteField.sendKeys('c'); + await autocompleteField.sendKeys(key.ARROW_DOWN); + await autocompleteField.sendKeys(key.ENTER); + + autocompleteField = await autocompleteField.getAttribute('value'); + expect(autocompleteField).toEqual('Cook County, IL'); + }); + + it('Should clear the input value by keyboard', async() => { + await driver.get(rootURL); + + let autocompleteField = await getElementByXPath( + '//*[@id="autocomplete_1"]' + ); + const clearSearch = await getElementByXPath( + '//*[@id="js-example"]/div/div[1]/button' + ); + + autocompleteField.click(); + await autocompleteField.sendKeys('c'); + await autocompleteField.sendKeys(key.ARROW_DOWN); + await autocompleteField.sendKeys(key.ENTER); + await autocompleteField.sendKeys(key.TAB); + + /* Assert the clear search button has keyboard focus, then click it. + * We are using the Selenium driver object to determine keyboard + * focus after sending the TAB key. + */ + expect(await getFocusInnerText()).toEqual('Clear search'); + + clearSearch.click(); + + autocompleteField = await getElementByXPath('//*[@id="autocomplete_1"]'); + autocompleteField = await autocompleteField.getAttribute('value'); + expect(autocompleteField).toEqual(''); + }); + + it('Closes the listbox when ESC is pressed', async() => { + await driver.get(rootURL); + + const autocompleteField = await getElementByXPath( + '//*[@id="autocomplete_1"]' + ); + autocompleteField.click(); + await autocompleteField.sendKeys('c'); + let listbox = await driver.findElements( + by.css('#autocomplete_owned_container_4') + ); + expect(listbox.length).toEqual(1); + + await autocompleteField.sendKeys(key.ESCAPE); + listbox = await driver.findElements( + by.css('#autocomplete_owned_container_4') + ); + expect(listbox.length).toEqual(0); }); it('Should have no accessibility violations', async() => { diff --git a/packages/core/src/components/Autocomplete/Autocomplete.example.jsx b/packages/core/src/components/Autocomplete/Autocomplete.example.jsx index 8d943470c6..8b23c1d68f 100644 --- a/packages/core/src/components/Autocomplete/Autocomplete.example.jsx +++ b/packages/core/src/components/Autocomplete/Autocomplete.example.jsx @@ -81,7 +81,7 @@ ReactDOM.render( /> - + - + - + - {this.props.loadingMessage} - - ); - } - + // If we have results, create a mapped list if (items.length) { return items.map((item, index) => (
  • + {this.props.loadingMessage} +
  • + ); + } + + // If we have no results, show the non-selected message return ( -
  • +
  • {this.props.noResultsMessage}
  • ); @@ -76,13 +105,19 @@ export class Autocomplete extends React.PureComponent { return React.Children.map(this.props.children, child => { if (isTextField(child)) { const propOverrides = { + 'aria-autocomplete': 'list', 'aria-controls': isOpen ? this.listboxId : null, + 'aria-expanded': isOpen, + 'aria-labelledby': null, + 'aria-owns': isOpen ? this.listboxId : null, autoComplete: this.props.autoCompleteLabel, focusTrigger: this.props.focusTrigger, id: this.id, + labelId: this.labelId, onBlur: child.props.onBlur, onChange: child.props.onChange, - onKeyDown: child.props.onKeyDown + onKeyDown: child.props.onKeyDown, + role: 'combobox' }; return React.cloneElement(child, getInputProps(propOverrides)); @@ -101,9 +136,16 @@ export class Autocomplete extends React.PureComponent { loading, children, className, + clearSearchButton, ...autocompleteProps } = this.props; + // See https://github.com/downshift-js/downshift#getrootprops + // Custom container returns a plain div, without the ARIA markup + // required for a WAI-ARIA 1.1 combobox. See the comments at the + // top of the component file for an explanation of this decision. + const MyDiv = ({ innerRef, ...rest }) =>
    ; + const rootClassName = classNames( 'ds-u-clearfix', 'ds-c-autocomplete', @@ -111,32 +153,46 @@ export class Autocomplete extends React.PureComponent { ); return ( - + {({ clearSelection, getInputProps, getItemProps, + getRootProps, highlightedIndex, inputValue, isOpen }) => ( -
    + {this.renderChildren(getInputProps, isOpen)} {isOpen && (loading || items) ? ( -
    +
    {label && !loading && (
    {label}
    )}
      ) : null} - -
    + {clearSearchButton && ( + + )} + )} - {...autocompleteProps} - /> + ); } } @@ -173,6 +230,7 @@ Autocomplete.defaultProps = { ariaClearLabel: 'Clear typeahead and search again', autoCompleteLabel: 'off', clearInputText: 'Clear search', + clearSearchButton: true, itemToString: item => (item ? item.name : ''), loadingMessage: 'Loading...', noResultsMessage: 'No results' @@ -196,9 +254,13 @@ Autocomplete.propTypes = { */ className: PropTypes.string, /** - * Clear search text that will appear on the page as part of the rendered `