diff --git a/packages/terra-form-select/CHANGELOG.md b/packages/terra-form-select/CHANGELOG.md index 63303b22e08..bae502901a1 100644 --- a/packages/terra-form-select/CHANGELOG.md +++ b/packages/terra-form-select/CHANGELOG.md @@ -2,10 +2,13 @@ ## Unreleased +* Fixed + * Fixed screen reader response for `terra-form-select-combobox`. + ## 6.51.0 - (November 21, 2023) * Added - * Added 'aria-invalid' attribute which will be set to true for error input fields and false when resolving errors. + * Added 'aria-invalid' attribute for `terra-form-select-combobox` ## 6.50.0 - (November 13, 2023) diff --git a/packages/terra-form-select/src/Combobox.jsx b/packages/terra-form-select/src/Combobox.jsx index 51ca8a1514f..9e6848d19ea 100644 --- a/packages/terra-form-select/src/Combobox.jsx +++ b/packages/terra-form-select/src/Combobox.jsx @@ -237,6 +237,7 @@ class Combobox extends React.Component { clearOptionDisplay={clearOptionDisplay} inputId={inputId} resetComboboxValue={this.handleResetComboboxValue} + allowClear={allowClear} > {this.state.tags} {children} diff --git a/packages/terra-form-select/src/SearchSelect.jsx b/packages/terra-form-select/src/SearchSelect.jsx index 4e730f2485e..b5894d13780 100644 --- a/packages/terra-form-select/src/SearchSelect.jsx +++ b/packages/terra-form-select/src/SearchSelect.jsx @@ -231,6 +231,7 @@ class SearchSelect extends React.Component { clearOptionDisplay={clearOptionDisplay} inputId={inputId} resetComboboxValue={this.handleResetComboboxValue} + allowClear={allowClear} > {children} diff --git a/packages/terra-form-select/src/combobox/Frame.jsx b/packages/terra-form-select/src/combobox/Frame.jsx index fac99b0257b..fd5dcf5ff4a 100644 --- a/packages/terra-form-select/src/combobox/Frame.jsx +++ b/packages/terra-form-select/src/combobox/Frame.jsx @@ -120,6 +120,10 @@ const propTypes = { * Callback function to reset input value after search */ resetComboboxValue: PropTypes.func, + /** + * Whether a clear option is available to clear the selection, will use placeholder text if provided. + */ + allowClear: PropTypes.bool, }; const defaultProps = { @@ -139,6 +143,7 @@ const defaultProps = { totalOptions: undefined, value: undefined, inputId: undefined, + allowClear: false, }; /* This rule can be removed when eslint-plugin-jsx-a11y is updated to ~> 6.0.0 */ @@ -263,6 +268,7 @@ class Frame extends React.Component { * @param {event} event - The onKeyDown event. */ handleKeyDown(event) { + const { intl } = this.props; const { keyCode, target } = event; if (keyCode === KeyCode.KEY_SPACE && target !== this.input) { @@ -285,6 +291,17 @@ class Frame extends React.Component { searchValue: '', }); event.stopPropagation(); + } else if (keyCode === KeyCode.KEY_ESCAPE && this.props.allowClear) { + this.hasEscPressed = false; + if (this.props.resetComboboxValue) { + this.props.resetComboboxValue(); + } + this.setState({ + hasSearchChanged: false, + searchValue: '', + }); + this.visuallyHiddenComponent.current.innerText = intl.formatMessage({ id: 'Terra.form.select.selectCleared' }); + event.stopPropagation(); } } @@ -646,6 +663,8 @@ class Frame extends React.Component { required, totalOptions, value, + allowClear, + resetComboboxValue, ...customProps } = this.props; diff --git a/packages/terra-form-select/src/combobox/Menu.jsx b/packages/terra-form-select/src/combobox/Menu.jsx index 81ea1c74e08..42c5f415bae 100644 --- a/packages/terra-form-select/src/combobox/Menu.jsx +++ b/packages/terra-form-select/src/combobox/Menu.jsx @@ -8,7 +8,6 @@ import * as KeyCode from 'keycode-js'; import AddOption from '../shared/_AddOption'; import ClearOption from '../shared/_ClearOption'; import MenuUtil from '../shared/_MenuUtil'; -import SharedUtil from '../shared/_SharedUtil'; import SearchResults from '../shared/_SearchResults'; import styles from '../shared/_Menu.module.scss'; import NoResults from '../shared/_NoResults'; @@ -104,7 +103,9 @@ class Menu extends React.Component { constructor(props) { super(props); - this.state = {}; + this.state = { + closedViaKeyEvent: false, + }; this.clearScrollTimeout = this.clearScrollTimeout.bind(this); this.handleKeyDown = this.handleKeyDown.bind(this); @@ -177,7 +178,6 @@ class Menu extends React.Component { } componentDidUpdate() { - this.updateNoResultsScreenReader(); this.updateCurrentActiveScreenReader(); } @@ -239,8 +239,8 @@ class Menu extends React.Component { results in reading the display text followed by reading the aria-live message which is the display text + 'selected' */ - if (!SharedUtil.isSafari()) { - this.props.visuallyHiddenComponent.current.innerText = `${option.props.display} ${selectedTxt}`; + if (option.props.value) { + this.props.visuallyHiddenComponent.current.innerText = `${option.props.value} ${selectedTxt}`; } } @@ -299,30 +299,23 @@ class Menu extends React.Component { return this.state.active === this.props.value; } - updateNoResultsScreenReader() { + updateNoResultsScreenReader(freeTextValue) { if (this.liveRegionTimeOut) { clearTimeout(this.liveRegionTimeOut); } this.liveRegionTimeOut = setTimeout(() => { - const { hasNoResults } = this.state; - const { intl, visuallyHiddenComponent, searchValue, } = this.props; - // Race condition can occur between calling timeout and unmounting this component. if (!visuallyHiddenComponent || !visuallyHiddenComponent.current) { return; } - - if (hasNoResults) { - visuallyHiddenComponent.current.innerText = intl.formatMessage({ id: 'Terra.form.select.noResults' }, { text: searchValue }); - } else { - visuallyHiddenComponent.current.innerText = ''; - } + const noMatchingResultText = intl.formatMessage({ id: 'Terra.form.select.noResults' }, { text: searchValue }); + visuallyHiddenComponent.current.innerText = `${noMatchingResultText}, ${freeTextValue}`; }, 1000); } @@ -333,7 +326,7 @@ class Menu extends React.Component { visuallyHiddenComponent, } = this.props; - const clearSelectTxt = intl.formatMessage({ id: 'Terra.form.select.clearSelect' }); + const { hasNoResults } = this.state; if (this.menu !== null && this.state.active !== null) { this.menu.setAttribute('aria-activedescendant', `terra-select-option-${this.state.active}`); @@ -346,17 +339,22 @@ class Menu extends React.Component { // Detects if option is clear option and provides accessible text if (clearOptionDisplay) { - const active = this.menu.querySelector('[data-select-active]'); + const active = this.menu && this.menu.querySelector('[data-select-active]'); if (active && active.hasAttribute('data-terra-select-clear-option')) { - visuallyHiddenComponent.current.innerText = clearSelectTxt; + // To match visual label and the text exposed by screen reader + visuallyHiddenComponent.current.innerText = clearOptionDisplay; } } // Detects if option is an "Add option" and provides accessible text - const active = this.menu.querySelector('[data-select-active]'); + const active = this.menu && this.menu.querySelector('[data-select-active]'); if (active && active.hasAttribute('data-terra-select-add-option')) { const display = active.querySelector('[data-terra-add-option]') ? active.querySelector('[data-terra-add-option]').innerText : null; - visuallyHiddenComponent.current.innerText = display; + if (hasNoResults && !this.state.closedViaKeyEvent) { + this.updateNoResultsScreenReader(display); + } else { + visuallyHiddenComponent.current.innerText = display; + } } const optGroupElement = MenuUtil.getOptGroupElement(this.props.children, this.state.active); @@ -376,7 +374,7 @@ class Menu extends React.Component { if (element.props.display === '' && element.props.value === '') { // Used for case where users selects clear option and opens // dropdown again and navigates to clear option - visuallyHiddenComponent.current.innerText = clearSelectTxt; + visuallyHiddenComponent.current.innerText = clearOptionDisplay; } else if (this.isActiveSelected()) { visuallyHiddenComponent.current.innerText = intl.formatMessage({ id: 'Terra.form.select.selectedText' }, { text: displayText, index, totalOptions }); } else { diff --git a/packages/terra-form-select/src/search/Frame.jsx b/packages/terra-form-select/src/search/Frame.jsx index 33f2a133e57..c9a75b20098 100644 --- a/packages/terra-form-select/src/search/Frame.jsx +++ b/packages/terra-form-select/src/search/Frame.jsx @@ -120,6 +120,10 @@ const propTypes = { * Callback function to reset input value after search */ resetComboboxValue: PropTypes.func, + /** + * Whether a clear option is available to clear the selection, will use placeholder text if provided. + */ + allowClear: PropTypes.bool, }; const defaultProps = { @@ -139,6 +143,7 @@ const defaultProps = { totalOptions: undefined, value: undefined, inputId: undefined, + allowClear: false, }; /* This rule can be removed when eslint-plugin-jsx-a11y is updated to ~> 6.0.0 */ @@ -253,6 +258,7 @@ class Frame extends React.Component { * @param {event} event - The onKeyDown event. */ handleKeyDown(event) { + const { intl } = this.props; const { keyCode, target } = event; if (keyCode === KeyCode.KEY_SPACE && target !== this.input) { @@ -275,6 +281,17 @@ class Frame extends React.Component { searchValue: '', }); event.stopPropagation(); + } else if (keyCode === KeyCode.KEY_ESCAPE && this.props.allowClear) { + this.hasEscPressed = false; + if (this.props.resetComboboxValue) { + this.props.resetComboboxValue(); + } + this.setState({ + hasSearchChanged: false, + searchValue: '', + }); + this.visuallyHiddenComponent.current.innerText = intl.formatMessage({ id: 'Terra.form.select.selectCleared' }); + event.stopPropagation(); } } @@ -634,6 +651,8 @@ class Frame extends React.Component { required, totalOptions, value, + allowClear, + resetComboboxValue, ...customProps } = this.props; diff --git a/packages/terra-form-select/src/search/Menu.jsx b/packages/terra-form-select/src/search/Menu.jsx index 5fb6b72e54e..a8859c43161 100644 --- a/packages/terra-form-select/src/search/Menu.jsx +++ b/packages/terra-form-select/src/search/Menu.jsx @@ -8,7 +8,6 @@ import * as KeyCode from 'keycode-js'; import ClearOption from '../shared/_ClearOption'; import NoResults from '../shared/_NoResults'; import MenuUtil from '../shared/_MenuUtil'; -import SharedUtil from '../shared/_SharedUtil'; import styles from '../shared/_Menu.module.scss'; const cx = classNamesBind.bind(styles); @@ -214,7 +213,7 @@ class Menu extends React.Component { results in reading the display text followed by reading the aria-live message which is the display text + 'selected' */ - if (!SharedUtil.isSafari()) { + if (option.props.display !== this.props.input.placeholder) { this.props.visuallyHiddenComponent.current.innerText = `${option.props.display} ${selectedTxt}`; } onSelect(option.props.value, option); @@ -303,8 +302,6 @@ class Menu extends React.Component { visuallyHiddenComponent, } = this.props; - const clearSelectTxt = intl.formatMessage({ id: 'Terra.form.select.clearSelect' }); - if (this.menu !== null && this.state.active !== null) { this.menu.setAttribute('aria-activedescendant', `terra-select-option-${this.state.active}`); } @@ -318,7 +315,7 @@ class Menu extends React.Component { if (clearOptionDisplay) { const active = this.menu.querySelector('[data-select-active]'); if (active && active.hasAttribute('data-terra-select-clear-option')) { - visuallyHiddenComponent.current.innerText = clearSelectTxt; + visuallyHiddenComponent.current.innerText = clearOptionDisplay; } } @@ -339,7 +336,7 @@ class Menu extends React.Component { if (element.props.display === '' && element.props.value === '') { // Used for case where users selects clear option and opens // dropdown again and navigates to clear option - visuallyHiddenComponent.current.innerText = clearSelectTxt; + visuallyHiddenComponent.current.innerText = clearOptionDisplay; } else if (this.isActiveSelected()) { visuallyHiddenComponent.current.innerText = intl.formatMessage({ id: 'Terra.form.select.selectedText' }, { text: displayText, index, totalOptions }); } else { diff --git a/packages/terra-form-select/tests/jest/__snapshots__/Frame.test.jsx.snap b/packages/terra-form-select/tests/jest/__snapshots__/Frame.test.jsx.snap index 609bf7413ef..d14c3cab979 100644 --- a/packages/terra-form-select/tests/jest/__snapshots__/Frame.test.jsx.snap +++ b/packages/terra-form-select/tests/jest/__snapshots__/Frame.test.jsx.snap @@ -345,6 +345,7 @@ exports[`Frame should render a clear option 1`] = ` exports[`Frame should render a combobox variant 1`] = `