From ab42d2d77f3dd9ee1afea78f5290d25a5a7d8961 Mon Sep 17 00:00:00 2001 From: Charles Lee Date: Wed, 2 May 2018 11:28:18 +1000 Subject: [PATCH 01/18] remove unnecessary aria props from Input and Group components --- src/Select.js | 8 -------- src/components/Group.js | 14 +------------- 2 files changed, 1 insertion(+), 21 deletions(-) diff --git a/src/Select.js b/src/Select.js index 198c2c9ef3..d38c3e0f92 100644 --- a/src/Select.js +++ b/src/Select.js @@ -1125,15 +1125,12 @@ export default class Select extends Component { // aria attributes makes the JSX "noisy", separated for clarity const ariaAttributes = { - 'aria-activedescendant': this.getActiveDescendentId(), 'aria-autocomplete': 'list', 'aria-busy': isLoading, - 'aria-describedby': this.props['aria-describedby'], 'aria-expanded': menuIsOpen, 'aria-haspopup': menuIsOpen, 'aria-label': this.props['aria-label'], 'aria-labelledby': this.props['aria-labelledby'], - 'aria-owns': menuIsOpen ? this.getElementId('listbox') : undefined, role: 'combobox', }; @@ -1386,11 +1383,6 @@ export default class Select extends Component { {...commonProps} {...group} Heading={GroupHeading} - innerProps={{ - 'aria-expanded': true, - 'aria-labelledby': headingId, - role: 'group', - }} headingProps={{ id: headingId, }} diff --git a/src/components/Group.js b/src/components/Group.js index 6dc5f340c7..c82abfacb8 100644 --- a/src/components/Group.js +++ b/src/components/Group.js @@ -10,16 +10,6 @@ type ComponentProps = { children: Node, /** Component to wrap the label, recieves headingProps. */ Heading: ComponentType, - /** Props passed to the heading. */ - headingProps: { - 'aria-labelledby': string, - }, - /** props passed to the wrapping element for the group. */ - innerProps: { - 'aria-expanded': boolean, - 'aria-label': string, - role: 'group', - }, /** Label to be displayed in the heading component. */ label: Node, }; @@ -37,9 +27,7 @@ const Group = (props: GroupProps) => { cx, getStyles, Heading, - headingProps, label, - innerProps, } = props; return (
{ )} {...innerProps} > - + {label}
{children}
From 945573647dac77318f5dcb46731ea136a5bd20ca Mon Sep 17 00:00:00 2001 From: Charles Lee Date: Wed, 2 May 2018 11:37:27 +1000 Subject: [PATCH 02/18] removed unnecessary aria attributes from Menu and Option --- src/Select.js | 8 -------- src/components/Menu.js | 9 ++------- src/components/Option.js | 2 -- 3 files changed, 2 insertions(+), 17 deletions(-) diff --git a/src/Select.js b/src/Select.js index d38c3e0f92..730591dbb5 100644 --- a/src/Select.js +++ b/src/Select.js @@ -56,8 +56,6 @@ type FormatOptionLabelMeta = { }; export type Props = { - /* HTML ID(s) of element(s) that should be used to describe this input (for assistive tech) */ - 'aria-describedby'?: string, /* Aria label (for assistive tech) */ 'aria-label'?: string, /* HTML ID of an element that should be used as the label (for assistive tech) */ @@ -1026,7 +1024,6 @@ export default class Select extends Component { return { innerProps: { - 'aria-selected': isSelected, id: optionId, onClick: onSelect, onMouseMove: onHover, @@ -1246,7 +1243,6 @@ export default class Select extends Component { const innerProps = { onMouseDown: this.onClearIndicatorMouseDown, onTouchEnd: this.onClearIndicatorTouchEnd, - role: 'button', }; return ( @@ -1337,7 +1333,6 @@ export default class Select extends Component { captureMenuScroll, inputValue, isLoading, - isMulti, loadingMessage, minMenuHeight, maxMenuHeight, @@ -1437,10 +1432,7 @@ export default class Select extends Component { Date: Wed, 2 May 2018 11:40:35 +1000 Subject: [PATCH 03/18] remove allytext from loadingIndicator --- src/components/indicators.js | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/components/indicators.js b/src/components/indicators.js index 39b3ab61d7..c6012cc2f0 100644 --- a/src/components/indicators.js +++ b/src/components/indicators.js @@ -2,7 +2,6 @@ import React, { type ElementType } from 'react'; import { injectGlobal, css } from 'emotion'; -import { A11yText } from '../primitives'; import { colors, spacing } from '../theme'; import { type CommonProps } from '../types'; @@ -204,7 +203,6 @@ export const LoadingIndicator = (props: LoadingIconProps) => { - Loading
); }; From 8bba32596e850d8d57b3a9bea9c42f240df12b3e Mon Sep 17 00:00:00 2001 From: Charles Lee Date: Wed, 2 May 2018 11:46:27 +1000 Subject: [PATCH 04/18] aria hidden true on indicators and svgs --- src/Select.js | 2 +- src/components/indicators.js | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Select.js b/src/Select.js index 730591dbb5..a341bd8c36 100644 --- a/src/Select.js +++ b/src/Select.js @@ -1262,7 +1262,7 @@ export default class Select extends Component { if (!LoadingIndicator || !isLoading) return null; const innerProps = { - role: 'presentation', + 'aria-hidden': 'true', }; return ( diff --git a/src/components/indicators.js b/src/components/indicators.js index c6012cc2f0..915ba01d44 100644 --- a/src/components/indicators.js +++ b/src/components/indicators.js @@ -14,6 +14,8 @@ const Svg = ({ size, ...props }: { size: number }) => ( height={size} width={size} viewBox="0 0 20 20" + aria-hidden="true" + focusable="false" className={css({ display: 'inline-block', fill: 'currentColor', @@ -26,12 +28,12 @@ const Svg = ({ size, ...props }: { size: number }) => ( ); export const CrossIcon = (props: any) => ( - + ); export const DownChevron = (props: any) => ( - + ); From 774e9793d0cc70368bc96d0d7a7a8745252ecdae Mon Sep 17 00:00:00 2001 From: Charles Lee Date: Wed, 2 May 2018 11:52:02 +1000 Subject: [PATCH 05/18] clean up flow types LoadingIndicator class, add aria-hidden to passed in innerProps --- src/Select.js | 10 +++------- src/components/indicators.js | 9 ++++++++- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/Select.js b/src/Select.js index a341bd8c36..e3d16e64d5 100644 --- a/src/Select.js +++ b/src/Select.js @@ -1243,6 +1243,7 @@ export default class Select extends Component { const innerProps = { onMouseDown: this.onClearIndicatorMouseDown, onTouchEnd: this.onClearIndicatorTouchEnd, + 'aria-hidden': 'true', }; return ( @@ -1261,10 +1262,7 @@ export default class Select extends Component { if (!LoadingIndicator || !isLoading) return null; - const innerProps = { - 'aria-hidden': 'true', - }; - + const innerProps = { 'aria-hidden': 'true' }; return ( { const { commonProps } = this; const { isDisabled } = this.props; const { isFocused } = this.state; - const innerProps = { role: 'presentation' }; return ( @@ -1304,7 +1300,7 @@ export default class Select extends Component { const innerProps = { onMouseDown: this.onDropdownIndicatorMouseDown, onTouchEnd: this.onDropdownIndicatorTouchEnd, - role: 'button', + 'aria-hidden': 'true', }; return ( diff --git a/src/components/indicators.js b/src/components/indicators.js index 915ba01d44..78c37e32a5 100644 --- a/src/components/indicators.js +++ b/src/components/indicators.js @@ -182,7 +182,14 @@ injectGlobal`@keyframes ${keyframesName} { 40% { opacity: 1; } };`; -export type LoadingIconProps = IndicatorProps & { +export type LoadingIconProps = { + /** Props that will be passed on to the children. */ + innerProps: any, + /** The focused state of the select. */ + isFocused: boolean, + /** Whether the text is right to left */ + isRtl: boolean, +} & CommonProps & { /** Set size of the container. */ size: number, }; From 27a7d66184b66581ed6c7d016ad35b52d1c0a755 Mon Sep 17 00:00:00 2001 From: Charles Lee Date: Mon, 7 May 2018 10:22:06 +1000 Subject: [PATCH 06/18] initial live-region implementation --- src/Select.js | 39 +++++++++++++++++++++++++++++---------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/Select.js b/src/Select.js index e3d16e64d5..0bd5361b0b 100644 --- a/src/Select.js +++ b/src/Select.js @@ -236,6 +236,8 @@ type MenuOptions = { type State = { inputIsHidden: boolean, isFocused: boolean, + instructions: string, + feedback: string, focusedOption: OptionType | null, focusedValue: OptionType | null, menuOptions: MenuOptions, @@ -374,9 +376,17 @@ export default class Select extends Component { // ============================== onMenuOpen() { + this.setState({ + instructions: 'Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.', + }); this.props.onMenuOpen(); } onMenuClose() { + const { isSearchable } = this.props; + console.log('onMenuClose is being called'); + this.setState({ + instructions: `Select is focused, ${ isSearchable ? 'type to refine list' : '' } press Down to open the menu`, + }); this.onInputChange('', { action: 'menu-close' }); this.props.onMenuClose(); } @@ -837,12 +847,14 @@ export default class Select extends Component { this.onMenuOpen(); }; onInputFocus = (event: SyntheticFocusEvent) => { + const { isSearchable } = this.props; if (this.props.onFocus) { this.props.onFocus(event); } this.inputIsHiddenAfterUpdate = false; this.setState({ isFocused: true, + instructions: `Select is focused, ${ isSearchable ? 'type to refine list' : '' } press Down to open the menu`, }); if (this.openAfterFocus || this.props.openMenuOnFocus) { this.openMenu('first'); @@ -850,6 +862,7 @@ export default class Select extends Component { this.openAfterFocus = false; }; onInputBlur = (event: SyntheticFocusEvent) => { + console.log('onInputBlur is being called'); if (this.props.onBlur) { this.props.onBlur(event); } @@ -858,6 +871,8 @@ export default class Select extends Component { this.setState({ focusedValue: null, isFocused: false, + instructions: ' ', + feedback: ' ', }); }; onOptionHover = (focusedOption: OptionType) => { @@ -1080,23 +1095,29 @@ export default class Select extends Component { // ============================== // Renderers // ============================== + renderAssertive () { + const { screenReaderStatus } = this.props; + return ( + +  {screenReaderStatus({ count: this.countOptions() })} + + ); + } renderScreenReaderStatus() { - const { screenReaderStatus } = this.props; + const { instructions } = this.state; return ( - - {screenReaderStatus({ count: this.countOptions() })} + +  {instructions} ); } renderInput() { const { isDisabled, - isLoading, isSearchable, inputId, inputValue, - menuIsOpen, tabIndex, } = this.props; const { Input } = this.components; @@ -1123,12 +1144,8 @@ export default class Select extends Component { // aria attributes makes the JSX "noisy", separated for clarity const ariaAttributes = { 'aria-autocomplete': 'list', - 'aria-busy': isLoading, - 'aria-expanded': menuIsOpen, - 'aria-haspopup': menuIsOpen, 'aria-label': this.props['aria-label'], 'aria-labelledby': this.props['aria-labelledby'], - role: 'combobox', }; const { cx } = this.commonProps; @@ -1514,7 +1531,9 @@ export default class Select extends Component { isDisabled={isDisabled} isFocused={isFocused} > - {this.renderScreenReaderStatus()} + + {isFocused ? this.renderAssertive() : null} + {isFocused ? this.renderScreenReaderStatus() : null} Date: Tue, 8 May 2018 12:03:14 +1000 Subject: [PATCH 07/18] accessibility wip, added focused options and inputvalue change announcements --- src/Select.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/src/Select.js b/src/Select.js index 0bd5361b0b..0c747ef27f 100644 --- a/src/Select.js +++ b/src/Select.js @@ -334,6 +334,7 @@ export default class Select extends Component { const menuOptions = this.buildMenuOptions(nextProps, selectValue); const focusedValue = this.getNextFocusedValue(selectValue); const focusedOption = this.getNextFocusedOption(menuOptions.focusable); + this.announceStatus('feedback', focusedOption ? `${this.getOptionLabel(focusedOption)} option focused` : ''); this.setState({ menuOptions, selectValue, focusedOption, focusedValue }); } // some updates should toggle the state of the input visibility @@ -376,16 +377,14 @@ export default class Select extends Component { // ============================== onMenuOpen() { - this.setState({ - instructions: 'Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.', - }); + this.announceStatus('instructions', 'Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'); this.props.onMenuOpen(); } onMenuClose() { const { isSearchable } = this.props; console.log('onMenuClose is being called'); this.setState({ - instructions: `Select is focused, ${ isSearchable ? 'type to refine list' : '' } press Down to open the menu`, + instructions: `Select is focused ${ isSearchable ? ',type to refine list' : '' }, press Down to open the menu`, }); this.onInputChange('', { action: 'menu-close' }); this.props.onMenuClose(); @@ -496,6 +495,7 @@ export default class Select extends Component { nextFocus = options.length - 1; } this.scrollToFocusedOptionOnUpdate = true; + this.announceStatus('feedback', `${this.getOptionLabel(options[nextFocus])} option focused`); this.setState({ focusedOption: options[nextFocus], focusedValue: null, @@ -656,6 +656,10 @@ export default class Select extends Component { // Helpers // ============================== + announceStatus(type: string, msg: string) { + this.setState({ [type]: msg }); + } + hasValue() { const { selectValue } = this.state; return selectValue.length > 0; @@ -1095,11 +1099,15 @@ export default class Select extends Component { // ============================== // Renderers // ============================== + constructAnnouncement () { + const { screenReaderStatus, inputValue } = this.props; + const { feedback } = this.state; + return `${feedback} ${screenReaderStatus({ count: this.countOptions() })} ${inputValue ? `for search term ${inputValue}` : ''}`; + } renderAssertive () { - const { screenReaderStatus } = this.props; return ( -  {screenReaderStatus({ count: this.countOptions() })} +  {this.constructAnnouncement()} ); } From 363d6351edd2b97fde5eca4f5f3fc0ec83a001b6 Mon Sep 17 00:00:00 2001 From: Charles Lee Date: Tue, 8 May 2018 17:21:44 +1000 Subject: [PATCH 08/18] add utility methods for aria live region updates for option focus, selection and deselection --- src/Select.js | 28 +++++++++++++++++++++++----- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/src/Select.js b/src/Select.js index 0c747ef27f..7a89035d23 100644 --- a/src/Select.js +++ b/src/Select.js @@ -334,7 +334,7 @@ export default class Select extends Component { const menuOptions = this.buildMenuOptions(nextProps, selectValue); const focusedValue = this.getNextFocusedValue(selectValue); const focusedOption = this.getNextFocusedOption(menuOptions.focusable); - this.announceStatus('feedback', focusedOption ? `${this.getOptionLabel(focusedOption)} option focused` : ''); + this.getNextAnnouncement(nextProps, this.props, focusedOption); this.setState({ menuOptions, selectValue, focusedOption, focusedValue }); } // some updates should toggle the state of the input visibility @@ -345,6 +345,22 @@ export default class Select extends Component { delete this.inputIsHiddenAfterUpdate; } } + getNextAnnouncement ({ value: nextValue }: Props, { value }: Props, nextFocusedOption: OptionType) { + // If there is a new value, return; + if (value !== nextValue) { + return; + } + // If there is not a new value, check if the focusedOption has changed, if it has + if (nextFocusedOption !== this.state.focusedOption) { + let msg = ''; + if (nextFocusedOption) { + msg = `option ${getOptionLabel(nextFocusedOption)} focused`; + } + this.announceStatus('feedback', msg); + } + // Update announce a new message with updated feedback. + + } componentDidUpdate(prevProps: Props) { const { isDisabled, menuIsOpen } = this.props; const { isFocused } = this.state; @@ -382,7 +398,6 @@ export default class Select extends Component { } onMenuClose() { const { isSearchable } = this.props; - console.log('onMenuClose is being called'); this.setState({ instructions: `Select is focused ${ isSearchable ? ',type to refine list' : '' }, press Down to open the menu`, }); @@ -528,11 +543,14 @@ export default class Select extends Component { 'deselect-option', newValue ); + this.announceStatus('feedback', `option ${this.getOptionLabel(newValue)}, deselected.`); } else { this.setValue([...selectValue, newValue], 'select-option', newValue); + this.announceStatus('feedback', `option ${this.getOptionLabel(newValue)}, selected.`); } } else { this.setValue(newValue, 'select-option'); + this.announceStatus('feedback', `option ${this.getOptionLabel(newValue)}, selected.`); } if (blurInputOnSelect) { @@ -547,6 +565,7 @@ export default class Select extends Component { action: 'remove-value', removedValue, }); + this.announceStatus('feedback', `value ${this.getOptionLabel(removedValue)} removed`); this.focusInput(); }; clearValue = () => { @@ -856,9 +875,9 @@ export default class Select extends Component { this.props.onFocus(event); } this.inputIsHiddenAfterUpdate = false; + this.announceStatus('instructions', `Select is focused ${ isSearchable ? ', type to refine list' : '' }, press Down to open the menu`,); this.setState({ isFocused: true, - instructions: `Select is focused, ${ isSearchable ? 'type to refine list' : '' } press Down to open the menu`, }); if (this.openAfterFocus || this.props.openMenuOnFocus) { this.openMenu('first'); @@ -866,7 +885,6 @@ export default class Select extends Component { this.openAfterFocus = false; }; onInputBlur = (event: SyntheticFocusEvent) => { - console.log('onInputBlur is being called'); if (this.props.onBlur) { this.props.onBlur(event); } @@ -1102,7 +1120,7 @@ export default class Select extends Component { constructAnnouncement () { const { screenReaderStatus, inputValue } = this.props; const { feedback } = this.state; - return `${feedback} ${screenReaderStatus({ count: this.countOptions() })} ${inputValue ? `for search term ${inputValue}` : ''}`; + return `${feedback} ${screenReaderStatus({ count: this.countOptions() })} ${inputValue ? `for search term ${inputValue}` : ' '}`; } renderAssertive () { return ( From 7b5089dedff7664bd76e55570e62b5ebf5a056dd Mon Sep 17 00:00:00 2001 From: Charles Lee Date: Tue, 5 Jun 2018 15:35:21 +1000 Subject: [PATCH 09/18] removed instructions from menuIsOpen and moved to focusOption and focusValue --- src/Select.js | 35 ++++++++++++++++++++++------------- src/components/Group.js | 1 - src/components/Menu.js | 4 +--- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/src/Select.js b/src/Select.js index 7a89035d23..1572b35bff 100644 --- a/src/Select.js +++ b/src/Select.js @@ -347,14 +347,12 @@ export default class Select extends Component { } getNextAnnouncement ({ value: nextValue }: Props, { value }: Props, nextFocusedOption: OptionType) { // If there is a new value, return; - if (value !== nextValue) { - return; - } + if (value && value !== nextValue) return; // If there is not a new value, check if the focusedOption has changed, if it has if (nextFocusedOption !== this.state.focusedOption) { let msg = ''; if (nextFocusedOption) { - msg = `option ${getOptionLabel(nextFocusedOption)} focused`; + msg += `option ${getOptionLabel(nextFocusedOption)} focused`; } this.announceStatus('feedback', msg); } @@ -393,14 +391,13 @@ export default class Select extends Component { // ============================== onMenuOpen() { - this.announceStatus('instructions', 'Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'); + // TODO: remove this, as instructions are explicitly to do with focus / pseudo focus changes. this.props.onMenuOpen(); } onMenuClose() { const { isSearchable } = this.props; - this.setState({ - instructions: `Select is focused ${ isSearchable ? ',type to refine list' : '' }, press Down to open the menu`, - }); + // TODO: remove this, as instructions are explicitly to do with focus / pseudo focus changes. + this.announceStatus('instructions', `Select is focused ${ isSearchable ? ',type to refine list' : '' }, press Down to open the menu`); this.onInputChange('', { action: 'menu-close' }); this.props.onMenuClose(); } @@ -457,8 +454,11 @@ export default class Select extends Component { this.setState({ focusedOption: null, }); - - const focusedIndex = focusedValue ? selectValue.indexOf(focusedValue) : -1; + let focusedIndex = selectValue.indexOf(focusedValue); + if (focusedValue) { + focusedIndex = -1; + this.announceStatus('instructions', 'Use left and right to toggle between focused values, press Enter to remove the currently focused value'); + } const lastIndex = selectValue.length - 1; let nextFocus = -1; if (!selectValue.length) return; @@ -495,7 +495,12 @@ export default class Select extends Component { if (!options.length) return; let nextFocus = 0; // handles 'first' - const focusedIndex = focusedOption ? options.indexOf(focusedOption) : -1; + let focusedIndex = options.indexOf(focusedOption); + if (!focusedOption) { + focusedIndex = -1; + this.announceStatus('instructions', 'Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'); + } + if (direction === 'up') { nextFocus = focusedIndex > 0 ? focusedIndex - 1 : options.length - 1; } else if (direction === 'down') { @@ -870,12 +875,16 @@ export default class Select extends Component { this.onMenuOpen(); }; onInputFocus = (event: SyntheticFocusEvent) => { - const { isSearchable } = this.props; + const { isSearchable, isMulti } = this.props; if (this.props.onFocus) { this.props.onFocus(event); } + let msg = `Select is focused ${ isSearchable ? ', type to refine list' : '' }, press Down to open the menu`; this.inputIsHiddenAfterUpdate = false; - this.announceStatus('instructions', `Select is focused ${ isSearchable ? ', type to refine list' : '' }, press Down to open the menu`,); + if (isMulti) { + msg += 'press left to focus selected values'; + }; + this.announceStatus('instructions', msg); this.setState({ isFocused: true, }); diff --git a/src/components/Group.js b/src/components/Group.js index c82abfacb8..ef960053f3 100644 --- a/src/components/Group.js +++ b/src/components/Group.js @@ -36,7 +36,6 @@ const Group = (props: GroupProps) => { { 'group': true }, className, )} - {...innerProps} > {label} diff --git a/src/components/Menu.js b/src/components/Menu.js index 557b8915d2..9a763592ad 100644 --- a/src/components/Menu.js +++ b/src/components/Menu.js @@ -333,8 +333,7 @@ export const menuListCSS = ({ maxHeight }: MenuState) => ({ WebkitOverflowScrolling: 'touch', }); export const MenuList = (props: MenuListComponentProps) => { - const { children, className, cx, getStyles, isMulti, innerProps } = props; - const { innerRef, ...rest } = innerProps; + const { children, className, cx, getStyles, isMulti, innerRef } = props; return (
{ className )} ref={innerRef} - {...rest} > {children}
From 5ccccc3d40df5d3a5af09e86e2a07ed9b685b4cf Mon Sep 17 00:00:00 2001 From: Charles Lee Date: Fri, 8 Jun 2018 15:49:42 +1000 Subject: [PATCH 10/18] react-aria-live implementation w aria-live-regions --- package.json | 1 + src/Select.js | 105 ++++++++++++++++++++++++++++++---------------- src/primitives.js | 16 ++++--- yarn.lock | 8 +++- 4 files changed, 89 insertions(+), 41 deletions(-) diff --git a/package.json b/package.json index bbe6170dcb..85ce52922f 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "emotion": "^9.1.2", "prop-types": "^15.6.0", "raf": "^3.4.0", + "react-aria-live": "^2.0.2", "react-input-autosize": "^2.2.1", "react-transition-group": "^2.2.1" }, diff --git a/src/Select.js b/src/Select.js index 1572b35bff..5ad07872b5 100644 --- a/src/Select.js +++ b/src/Select.js @@ -4,6 +4,7 @@ import React, { Component, type ElementRef, type Node } from 'react'; import { createFilter } from './filters'; import { DummyInput, ScrollBlock, ScrollCaptor } from './internal/index'; +import { LiveMessage, LiveAnnouncer } from 'react-aria-live'; import { classNames, @@ -242,6 +243,12 @@ type State = { focusedValue: OptionType | null, menuOptions: MenuOptions, selectValue: OptionsType, + a11yState: { + selection?: string, + valueFocus?: string, + optionFocus?: string, + instructions?: string + }, }; type ElRef = ElementRef<*>; @@ -334,7 +341,7 @@ export default class Select extends Component { const menuOptions = this.buildMenuOptions(nextProps, selectValue); const focusedValue = this.getNextFocusedValue(selectValue); const focusedOption = this.getNextFocusedOption(menuOptions.focusable); - this.getNextAnnouncement(nextProps, this.props, focusedOption); + // this.getNextAnnouncement(nextProps, this.props, focusedOption); this.setState({ menuOptions, selectValue, focusedOption, focusedValue }); } // some updates should toggle the state of the input visibility @@ -345,20 +352,6 @@ export default class Select extends Component { delete this.inputIsHiddenAfterUpdate; } } - getNextAnnouncement ({ value: nextValue }: Props, { value }: Props, nextFocusedOption: OptionType) { - // If there is a new value, return; - if (value && value !== nextValue) return; - // If there is not a new value, check if the focusedOption has changed, if it has - if (nextFocusedOption !== this.state.focusedOption) { - let msg = ''; - if (nextFocusedOption) { - msg += `option ${getOptionLabel(nextFocusedOption)} focused`; - } - this.announceStatus('feedback', msg); - } - // Update announce a new message with updated feedback. - - } componentDidUpdate(prevProps: Props) { const { isDisabled, menuIsOpen } = this.props; const { isFocused } = this.state; @@ -425,7 +418,6 @@ export default class Select extends Component { openMenu(focusOption: 'first' | 'last') { const { menuOptions, selectValue } = this.state; const { isMulti } = this.props; - let openAtIndex = focusOption === 'first' ? 0 : menuOptions.focusable.length - 1; @@ -436,16 +428,20 @@ export default class Select extends Component { } } + this.scrollToFocusedOptionOnUpdate = true; this.inputIsHiddenAfterUpdate = false; + this.onMenuOpen(); this.setState({ focusedValue: null, focusedOption: menuOptions.focusable[openAtIndex], + }, () => { + this.announceStatus('instructions', 'Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'); }); } focusValue(direction: 'previous' | 'next') { - const { isMulti } = this.props; + const { isMulti, isSearchable } = this.props; const { selectValue, focusedValue } = this.state; // Only multiselects support value focusing @@ -454,11 +450,13 @@ export default class Select extends Component { this.setState({ focusedOption: null, }); + let focusedIndex = selectValue.indexOf(focusedValue); - if (focusedValue) { + if (!focusedValue) { focusedIndex = -1; this.announceStatus('instructions', 'Use left and right to toggle between focused values, press Enter to remove the currently focused value'); } + const lastIndex = selectValue.length - 1; let nextFocus = -1; if (!selectValue.length) return; @@ -482,6 +480,10 @@ export default class Select extends Component { break; } + if (nextFocus === -1) { + this.announceStatus('instructions', `Select is focused ${ isSearchable ? ', type to refine list' : '' }, press Down to open the menu`); + } + this.setState({ inputIsHidden: nextFocus === -1 ? false : true, focusedValue: selectValue[nextFocus], @@ -515,7 +517,6 @@ export default class Select extends Component { nextFocus = options.length - 1; } this.scrollToFocusedOptionOnUpdate = true; - this.announceStatus('feedback', `${this.getOptionLabel(options[nextFocus])} option focused`); this.setState({ focusedOption: options[nextFocus], focusedValue: null, @@ -548,14 +549,14 @@ export default class Select extends Component { 'deselect-option', newValue ); - this.announceStatus('feedback', `option ${this.getOptionLabel(newValue)}, deselected.`); + this.announceStatus('selection', `option ${this.getOptionLabel(newValue)}, deselected.`); } else { this.setValue([...selectValue, newValue], 'select-option', newValue); this.announceStatus('feedback', `option ${this.getOptionLabel(newValue)}, selected.`); } } else { this.setValue(newValue, 'select-option'); - this.announceStatus('feedback', `option ${this.getOptionLabel(newValue)}, selected.`); + this.announceStatus('selection', `option ${this.getOptionLabel(newValue)}, selected.`); } if (blurInputOnSelect) { @@ -570,7 +571,7 @@ export default class Select extends Component { action: 'remove-value', removedValue, }); - this.announceStatus('feedback', `value ${this.getOptionLabel(removedValue)} removed`); + this.announceStatus('selection', `value ${this.getOptionLabel(removedValue)} removed`); this.focusInput(); }; clearValue = () => { @@ -637,7 +638,8 @@ export default class Select extends Component { } else if (lastFocusedIndex < nextSelectValue.length) { // the focusedValue is not present in the next selectValue array by // reference, so return the new value at the same index - return nextSelectValue[lastFocusedIndex]; + const nextFocusedValue = nextSelectValue[lastFocusedIndex]; + return nextFocusedValue; } } return null; @@ -645,9 +647,14 @@ export default class Select extends Component { getNextFocusedOption(options: OptionsType) { const { focusedOption: lastFocusedOption } = this.state; - return lastFocusedOption && options.indexOf(lastFocusedOption) > -1 - ? lastFocusedOption - : options[0]; + const nextFocusedOptionIndex = lastFocusedOption && options.indexOf(lastFocusedOption) > -1 ? options.indexOf(lastFocusedOption) : 0; + const nextFocusedOption = options[nextFocusedOptionIndex]; + + if (nextFocusedOption) { + this.announceStatus('optionFocus', `option ${this.props.getOptionLabel(nextFocusedOption)} now focused, ${nextFocusedOptionIndex + 1} of ${options.length}`); + } + + return nextFocusedOption; } getOptionLabel(data: OptionType): string { return this.props.getOptionLabel(data); @@ -681,7 +688,9 @@ export default class Select extends Component { // ============================== announceStatus(type: string, msg: string) { - this.setState({ [type]: msg }); + this.setState(state => ({ + a11yState: { ...state.a11yState, [type]: msg } + })); } hasValue() { @@ -902,8 +911,7 @@ export default class Select extends Component { this.setState({ focusedValue: null, isFocused: false, - instructions: ' ', - feedback: ' ', + a11yState: {}, }); }; onOptionHover = (focusedOption: OptionType) => { @@ -1131,19 +1139,36 @@ export default class Select extends Component { const { feedback } = this.state; return `${feedback} ${screenReaderStatus({ count: this.countOptions() })} ${inputValue ? `for search term ${inputValue}` : ' '}`; } + constructAriaLiveMessage () { + const { a11yState: xAllyState, selectValue, focusedValue, focusedOption } = this.state; + const { options, menuIsOpen, inputValue } = this.props; + return [ + focusedValue ?`value ${this.getOptionLabel(focusedValue)} focused, ${selectValue.indexOf(focusedValue) + 1} of ${selectValue.length}`: null, + (focusedOption && menuIsOpen) ? `option ${this.getOptionLabel(focusedOption)} focused, ${options.indexOf(focusedOption) + 1} of ${options.length}` : null, + inputValue ? `for inputValue ${inputValue}` : null, + xAllyState.instructions ? xAllyState.instructions : null, + ].join(' '); + } + renderAssertive () { + const { a11yState: xAllyState, selectValue, focusedValue, focusedOption } = this.state; + const { options, menuIsOpen, inputValue } = this.props; return ( -  {this.constructAnnouncement()} +  {xAllyState.selection} + {focusedValue ?  {`value ${this.getOptionLabel(focusedValue)} focused, ${selectValue.indexOf(focusedValue) + 1} of ${selectValue.length}`} : null} + {(focusedOption && menuIsOpen) ?  {`option ${this.getOptionLabel(focusedOption)} focused, ${options.indexOf(focusedOption) + 1} of ${options.length}`} : null} + {inputValue ?  {`for inputValue ${inputValue}`} : null} + {xAllyState.instructions ?  {xAllyState.instructions} : null} ); } renderScreenReaderStatus() { - const { instructions } = this.state; + const { a11yState } = this.state; return ( -  {instructions} +  {a11yState.instructions} ); } @@ -1566,9 +1591,19 @@ export default class Select extends Component { isDisabled={isDisabled} isFocused={isFocused} > - - {isFocused ? this.renderAssertive() : null} - {isFocused ? this.renderScreenReaderStatus() : null} + + + + + {/* {isFocused ? this.renderAssertive() : null} */} + {/* {isFocused ? this.renderScreenReaderStatus() : null} */} + ( diff --git a/yarn.lock b/yarn.lock index a3f414ef60..9193554fa7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8109,6 +8109,12 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.1.7: minimist "^1.2.0" strip-json-comments "~2.0.1" +react-aria-live@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/react-aria-live/-/react-aria-live-2.0.2.tgz#9cfc2352544275586f74b66d18c54a8b3b00398a" + dependencies: + uuid "^3.2.1" + react-codesandboxer@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/react-codesandboxer/-/react-codesandboxer-2.0.2.tgz#ff83b0a8970dc77be9a531aca6c4ee48dab003bf" @@ -10008,7 +10014,7 @@ uuid@^2.0.1: version "2.0.3" resolved "https://registry.yarnpkg.com/uuid/-/uuid-2.0.3.tgz#67e2e863797215530dff318e5bf9dcebfd47b21a" -uuid@^3.0.0, uuid@^3.0.1, uuid@^3.1.0: +uuid@^3.0.0, uuid@^3.0.1, uuid@^3.1.0, uuid@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.2.1.tgz#12c528bb9d58d0b9265d9a2f6f0fe8be17ff1f14" From 053edd75a4e9e63f6f30cbe293109e0ea3fd1625 Mon Sep 17 00:00:00 2001 From: Charles Lee Date: Mon, 18 Jun 2018 16:24:41 +1000 Subject: [PATCH 11/18] add instructions and valueEvent functions for defining ariaLiveSelection and ariaLiveContext messages, added ariaLiveSelection and ariaLiveContext messages --- src/Select.js | 125 +++++++++++++++++++++++++------------------------- 1 file changed, 63 insertions(+), 62 deletions(-) diff --git a/src/Select.js b/src/Select.js index 5ad07872b5..17489bbad1 100644 --- a/src/Select.js +++ b/src/Select.js @@ -4,7 +4,6 @@ import React, { Component, type ElementRef, type Node } from 'react'; import { createFilter } from './filters'; import { DummyInput, ScrollBlock, ScrollCaptor } from './internal/index'; -import { LiveMessage, LiveAnnouncer } from 'react-aria-live'; import { classNames, @@ -56,6 +55,35 @@ type FormatOptionLabelMeta = { selectValue: ValueType, }; +type InstructionsData = { event: string, context?: InstructionsContext}; +type InstructionsContext = { isSearchable?: boolean, isMulti?: boolean }; +type ValueEventData = {event: string, context: ValueEventContext}; +type ValueEventContext = { value: string }; + +const instructions = (event, context?: InstructionsContext = {}) => { + const { isSearchable, isMulti } = context; + switch (event) { + case 'menu': + return 'Use Up and Down to choose options, press Backspace to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'; + case 'value': + return `Select is focused ${ isSearchable ? ',type to refine list' : '' }, press Down to open the menu, ${ isMulti ? ' press left to focus selected values' : '' }`; + case 'input': + return 'Use left and right to toggle between focused values, press Enter to remove the currently focused value'; + } +}; + +const valueEvent = (event, context: ValueEventContext) => { + const { value } = context; + switch (event) { + case 'deselect-option': + case 'pop-value': + case 'remove-value': + return `option ${value}, deselected.`; + case 'select-option': + return `option ${value}, selected.`; + } +}; + export type Props = { /* Aria label (for assistive tech) */ 'aria-label'?: string, @@ -235,6 +263,8 @@ type MenuOptions = { }; type State = { + ariaLiveSelection: string, + ariaLiveContext: string, inputIsHidden: boolean, isFocused: boolean, instructions: string, @@ -388,9 +418,9 @@ export default class Select extends Component { this.props.onMenuOpen(); } onMenuClose() { - const { isSearchable } = this.props; + const { isSearchable, isMulti } = this.props; // TODO: remove this, as instructions are explicitly to do with focus / pseudo focus changes. - this.announceStatus('instructions', `Select is focused ${ isSearchable ? ',type to refine list' : '' }, press Down to open the menu`); + this.announceAriaLiveContext({ event: 'input', context: { isSearchable, isMulti }}); this.onInputChange('', { action: 'menu-close' }); this.props.onMenuClose(); } @@ -437,7 +467,7 @@ export default class Select extends Component { focusedValue: null, focusedOption: menuOptions.focusable[openAtIndex], }, () => { - this.announceStatus('instructions', 'Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'); + this.announceAriaLiveContext({ event: 'menu' }); }); } focusValue(direction: 'previous' | 'next') { @@ -454,7 +484,7 @@ export default class Select extends Component { let focusedIndex = selectValue.indexOf(focusedValue); if (!focusedValue) { focusedIndex = -1; - this.announceStatus('instructions', 'Use left and right to toggle between focused values, press Enter to remove the currently focused value'); + this.announceAriaLiveContext({ event: 'value' }); } const lastIndex = selectValue.length - 1; @@ -481,7 +511,7 @@ export default class Select extends Component { } if (nextFocus === -1) { - this.announceStatus('instructions', `Select is focused ${ isSearchable ? ', type to refine list' : '' }, press Down to open the menu`); + this.announceAriaLiveContext({ event: 'input', context: { isSearchable, isMulti } }); } this.setState({ @@ -500,7 +530,7 @@ export default class Select extends Component { let focusedIndex = options.indexOf(focusedOption); if (!focusedOption) { focusedIndex = -1; - this.announceStatus('instructions', 'Use Up and Down to choose options, press Enter to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'); + this.announceAriaLiveContext({ event: 'menu' }); } if (direction === 'up') { @@ -549,14 +579,15 @@ export default class Select extends Component { 'deselect-option', newValue ); - this.announceStatus('selection', `option ${this.getOptionLabel(newValue)}, deselected.`); + this.announceAriaLiveSelection({ event: 'deselect-option', context: { value: this.getOptionLabel(newValue) } }); } else { + this.setValue([...selectValue, newValue], 'select-option', newValue); - this.announceStatus('feedback', `option ${this.getOptionLabel(newValue)}, selected.`); + this.announceAriaLiveSelection({ event: 'select-option', context: { value: this.getOptionLabel(newValue) } }); } } else { this.setValue(newValue, 'select-option'); - this.announceStatus('selection', `option ${this.getOptionLabel(newValue)}, selected.`); + this.announceAriaLiveSelection({ event: 'select-option', context: { value: this.getOptionLabel(newValue) } }); } if (blurInputOnSelect) { @@ -571,7 +602,7 @@ export default class Select extends Component { action: 'remove-value', removedValue, }); - this.announceStatus('selection', `value ${this.getOptionLabel(removedValue)} removed`); + this.announceAriaLiveSelection({ event: 'remove-value', context: { value: this.getOptionLabel(removedValue) } }); this.focusInput(); }; clearValue = () => { @@ -581,9 +612,11 @@ export default class Select extends Component { popValue = () => { const { onChange } = this.props; const { selectValue } = this.state; + const lastSelectedValue = selectValue[selectValue.length - 1]; + this.announceAriaLiveSelection({ event: 'pop-value', context: { value: this.getOptionLabel(lastSelectedValue) } }); onChange(selectValue.slice(0, selectValue.length - 1), { action: 'pop-value', - removedValue: selectValue[selectValue.length - 1], + removedValue: lastSelectedValue, }); }; @@ -650,10 +683,6 @@ export default class Select extends Component { const nextFocusedOptionIndex = lastFocusedOption && options.indexOf(lastFocusedOption) > -1 ? options.indexOf(lastFocusedOption) : 0; const nextFocusedOption = options[nextFocusedOptionIndex]; - if (nextFocusedOption) { - this.announceStatus('optionFocus', `option ${this.props.getOptionLabel(nextFocusedOption)} now focused, ${nextFocusedOptionIndex + 1} of ${options.length}`); - } - return nextFocusedOption; } getOptionLabel(data: OptionType): string { @@ -686,12 +715,16 @@ export default class Select extends Component { // ============================== // Helpers // ============================== - - announceStatus(type: string, msg: string) { - this.setState(state => ({ - a11yState: { ...state.a11yState, [type]: msg } - })); + announceAriaLiveSelection = (data: ValueEventData) => { + this.setState({ + ariaLiveSelection: valueEvent(data.event, data.context), + }); } + announceAriaLiveContext = (data: InstructionsData) => { + this.setState({ + ariaLiveContext: instructions(data.event, data.context), + }); + }; hasValue() { const { selectValue } = this.state; @@ -888,12 +921,8 @@ export default class Select extends Component { if (this.props.onFocus) { this.props.onFocus(event); } - let msg = `Select is focused ${ isSearchable ? ', type to refine list' : '' }, press Down to open the menu`; this.inputIsHiddenAfterUpdate = false; - if (isMulti) { - msg += 'press left to focus selected values'; - }; - this.announceStatus('instructions', msg); + this.announceAriaLiveContext({ event: 'input', context: { isSearchable, isMulti } }); this.setState({ isFocused: true, }); @@ -1134,44 +1163,17 @@ export default class Select extends Component { // ============================== // Renderers // ============================== - constructAnnouncement () { - const { screenReaderStatus, inputValue } = this.props; - const { feedback } = this.state; - return `${feedback} ${screenReaderStatus({ count: this.countOptions() })} ${inputValue ? `for search term ${inputValue}` : ' '}`; - } constructAriaLiveMessage () { - const { a11yState: xAllyState, selectValue, focusedValue, focusedOption } = this.state; - const { options, menuIsOpen, inputValue } = this.props; + const { ariaLiveContext, selectValue, focusedValue, focusedOption } = this.state; + const { options, menuIsOpen, inputValue, screenReaderStatus } = this.props; return [ focusedValue ?`value ${this.getOptionLabel(focusedValue)} focused, ${selectValue.indexOf(focusedValue) + 1} of ${selectValue.length}`: null, (focusedOption && menuIsOpen) ? `option ${this.getOptionLabel(focusedOption)} focused, ${options.indexOf(focusedOption) + 1} of ${options.length}` : null, - inputValue ? `for inputValue ${inputValue}` : null, - xAllyState.instructions ? xAllyState.instructions : null, + inputValue ? `${screenReaderStatus({ count: this.countOptions() })} for search term ${inputValue}` : null, + ariaLiveContext ].join(' '); } - renderAssertive () { - const { a11yState: xAllyState, selectValue, focusedValue, focusedOption } = this.state; - const { options, menuIsOpen, inputValue } = this.props; - return ( - -  {xAllyState.selection} - {focusedValue ?  {`value ${this.getOptionLabel(focusedValue)} focused, ${selectValue.indexOf(focusedValue) + 1} of ${selectValue.length}`} : null} - {(focusedOption && menuIsOpen) ?  {`option ${this.getOptionLabel(focusedOption)} focused, ${options.indexOf(focusedOption) + 1} of ${options.length}`} : null} - {inputValue ?  {`for inputValue ${inputValue}`} : null} - {xAllyState.instructions ?  {xAllyState.instructions} : null} - - ); - } - - renderScreenReaderStatus() { - const { a11yState } = this.state; - return ( - -  {a11yState.instructions} - - ); - } renderInput() { const { isDisabled, @@ -1598,11 +1600,10 @@ export default class Select extends Component { top: 0, left: 0, }}> - - - - {/* {isFocused ? this.renderAssertive() : null} */} - {/* {isFocused ? this.renderScreenReaderStatus() : null} */} + +

 {this.state.ariaLiveSelection}

+

 {this.constructAriaLiveMessage()}

+
Date: Tue, 19 Jun 2018 08:21:35 +1000 Subject: [PATCH 12/18] added accessibility folder, added helper fucntions for aria messages for focusedValue, focusedOption and results context --- package.json | 1 - src/Select.js | 110 +++++++++++++------------------------ src/accessibility/index.js | 30 ++++++++++ src/primitives.js | 13 ++--- 4 files changed, 74 insertions(+), 80 deletions(-) create mode 100644 src/accessibility/index.js diff --git a/package.json b/package.json index 85ce52922f..bbe6170dcb 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,6 @@ "emotion": "^9.1.2", "prop-types": "^15.6.0", "raf": "^3.4.0", - "react-aria-live": "^2.0.2", "react-input-autosize": "^2.2.1", "react-transition-group": "^2.2.1" }, diff --git a/src/Select.js b/src/Select.js index 17489bbad1..18cfd231cd 100644 --- a/src/Select.js +++ b/src/Select.js @@ -4,6 +4,15 @@ import React, { Component, type ElementRef, type Node } from 'react'; import { createFilter } from './filters'; import { DummyInput, ScrollBlock, ScrollCaptor } from './internal/index'; +import { + valueFocusAriaMessage, + optionFocusAriaMessage, + resultsAriaMessage, + valueEventAriaMessage, + instructionsAriaMessage, + type InstructionsContext, + type ValueEventContext, +} from './accessibility'; import { classNames, @@ -55,34 +64,6 @@ type FormatOptionLabelMeta = { selectValue: ValueType, }; -type InstructionsData = { event: string, context?: InstructionsContext}; -type InstructionsContext = { isSearchable?: boolean, isMulti?: boolean }; -type ValueEventData = {event: string, context: ValueEventContext}; -type ValueEventContext = { value: string }; - -const instructions = (event, context?: InstructionsContext = {}) => { - const { isSearchable, isMulti } = context; - switch (event) { - case 'menu': - return 'Use Up and Down to choose options, press Backspace to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'; - case 'value': - return `Select is focused ${ isSearchable ? ',type to refine list' : '' }, press Down to open the menu, ${ isMulti ? ' press left to focus selected values' : '' }`; - case 'input': - return 'Use left and right to toggle between focused values, press Enter to remove the currently focused value'; - } -}; - -const valueEvent = (event, context: ValueEventContext) => { - const { value } = context; - switch (event) { - case 'deselect-option': - case 'pop-value': - case 'remove-value': - return `option ${value}, deselected.`; - case 'select-option': - return `option ${value}, selected.`; - } -}; export type Props = { /* Aria label (for assistive tech) */ @@ -251,7 +232,7 @@ export const defaultProps = { pageSize: 5, placeholder: 'Select...', screenReaderStatus: ({ count }: { count: number }) => - `${count} result${count !== 1 ? 's' : ''} available.`, + `${count} result${count !== 1 ? 's' : ''} available`, styles: {}, tabIndex: '0', tabSelectsValue: true, @@ -267,18 +248,10 @@ type State = { ariaLiveContext: string, inputIsHidden: boolean, isFocused: boolean, - instructions: string, - feedback: string, focusedOption: OptionType | null, focusedValue: OptionType | null, menuOptions: MenuOptions, selectValue: OptionsType, - a11yState: { - selection?: string, - valueFocus?: string, - optionFocus?: string, - instructions?: string - }, }; type ElRef = ElementRef<*>; @@ -288,6 +261,8 @@ let instanceId = 1; export default class Select extends Component { static defaultProps = defaultProps; state = { + ariaLiveSelection: '', + ariaLiveContext: '', focusedOption: null, focusedValue: null, inputIsHidden: false, @@ -371,7 +346,6 @@ export default class Select extends Component { const menuOptions = this.buildMenuOptions(nextProps, selectValue); const focusedValue = this.getNextFocusedValue(selectValue); const focusedOption = this.getNextFocusedOption(menuOptions.focusable); - // this.getNextAnnouncement(nextProps, this.props, focusedOption); this.setState({ menuOptions, selectValue, focusedOption, focusedValue }); } // some updates should toggle the state of the input visibility @@ -414,13 +388,11 @@ export default class Select extends Component { // ============================== onMenuOpen() { - // TODO: remove this, as instructions are explicitly to do with focus / pseudo focus changes. this.props.onMenuOpen(); } onMenuClose() { const { isSearchable, isMulti } = this.props; - // TODO: remove this, as instructions are explicitly to do with focus / pseudo focus changes. - this.announceAriaLiveContext({ event: 'input', context: { isSearchable, isMulti }}); + this.announceAriaLiveContext({ event: 'input', context: { isSearchable, isMulti } }); this.onInputChange('', { action: 'menu-close' }); this.props.onMenuClose(); } @@ -466,9 +438,9 @@ export default class Select extends Component { this.setState({ focusedValue: null, focusedOption: menuOptions.focusable[openAtIndex], - }, () => { - this.announceAriaLiveContext({ event: 'menu' }); }); + + this.announceAriaLiveContext({ event: 'menu' }); } focusValue(direction: 'previous' | 'next') { const { isMulti, isSearchable } = this.props; @@ -671,8 +643,7 @@ export default class Select extends Component { } else if (lastFocusedIndex < nextSelectValue.length) { // the focusedValue is not present in the next selectValue array by // reference, so return the new value at the same index - const nextFocusedValue = nextSelectValue[lastFocusedIndex]; - return nextFocusedValue; + return nextSelectValue[lastFocusedIndex]; } } return null; @@ -680,15 +651,14 @@ export default class Select extends Component { getNextFocusedOption(options: OptionsType) { const { focusedOption: lastFocusedOption } = this.state; - const nextFocusedOptionIndex = lastFocusedOption && options.indexOf(lastFocusedOption) > -1 ? options.indexOf(lastFocusedOption) : 0; - const nextFocusedOption = options[nextFocusedOptionIndex]; - - return nextFocusedOption; + return lastFocusedOption && options.indexOf(lastFocusedOption) > -1 + ? lastFocusedOption + : options[0]; } - getOptionLabel(data: OptionType): string { + getOptionLabel = (data: OptionType): string => { return this.props.getOptionLabel(data); } - getOptionValue(data: OptionType): string { + getOptionValue = (data: OptionType): string => { return this.props.getOptionValue(data); } getStyles = (key: string, props: {}): {} => { @@ -715,14 +685,14 @@ export default class Select extends Component { // ============================== // Helpers // ============================== - announceAriaLiveSelection = (data: ValueEventData) => { + announceAriaLiveSelection = ({ event, context }: { event: string, context: ValueEventContext }) => { this.setState({ - ariaLiveSelection: valueEvent(data.event, data.context), + ariaLiveSelection: valueEventAriaMessage(event, context), }); } - announceAriaLiveContext = (data: InstructionsData) => { + announceAriaLiveContext = ({ event, context }: { event: string, context?: InstructionsContext }) => { this.setState({ - ariaLiveContext: instructions(data.event, data.context), + ariaLiveContext: instructionsAriaMessage(event, { ...context, label: this.props['aria-label'] }), }); }; @@ -940,7 +910,6 @@ export default class Select extends Component { this.setState({ focusedValue: null, isFocused: false, - a11yState: {}, }); }; onOptionHover = (focusedOption: OptionType) => { @@ -1167,9 +1136,9 @@ export default class Select extends Component { const { ariaLiveContext, selectValue, focusedValue, focusedOption } = this.state; const { options, menuIsOpen, inputValue, screenReaderStatus } = this.props; return [ - focusedValue ?`value ${this.getOptionLabel(focusedValue)} focused, ${selectValue.indexOf(focusedValue) + 1} of ${selectValue.length}`: null, - (focusedOption && menuIsOpen) ? `option ${this.getOptionLabel(focusedOption)} focused, ${options.indexOf(focusedOption) + 1} of ${options.length}` : null, - inputValue ? `${screenReaderStatus({ count: this.countOptions() })} for search term ${inputValue}` : null, + focusedValue ? valueFocusAriaMessage({ focusedValue, getOptionLabel: this.getOptionLabel, selectValue }) : null, + (focusedOption && menuIsOpen) ? optionFocusAriaMessage({ focusedOption, getOptionLabel: this.getOptionLabel, options }) : null, + inputValue ? resultsAriaMessage({ inputValue, screenReaderMessage: screenReaderStatus({ count: this.countOptions() }) }) : null, ariaLiveContext ].join(' '); } @@ -1569,6 +1538,16 @@ export default class Select extends Component { } } + renderLiveRegion () { + if (!this.state.isFocused) return null; + return ( + +

 {this.state.ariaLiveSelection}

+

 {this.constructAriaLiveMessage()}

+
+ ); + } + render() { const { Control, @@ -1593,18 +1572,7 @@ export default class Select extends Component { isDisabled={isDisabled} isFocused={isFocused} > - - -

 {this.state.ariaLiveSelection}

-

 {this.constructAriaLiveMessage()}

-
-
+ {this.renderLiveRegion()} { + const { isSearchable, isMulti, label } = context; + switch (event) { + case 'menu': + return 'Use Up and Down to choose options, press Backspace to select the currently focused option, press Escape to exit the menu, press Tab to select the option and exit the menu.'; + case 'input': + return `${label ? label : 'Select'} is focused ${ isSearchable ? ',type to refine list' : '' }, press Down to open the menu, ${ isMulti ? ' press left to focus selected values' : '' }`; + case 'value': + return 'Use left and right to toggle between focused values, press Enter to remove the currently focused value'; + } +}; + +export const valueEventAriaMessage = (event, context: ValueEventContext) => { + const { value } = context; + switch (event) { + case 'deselect-option': + case 'pop-value': + case 'remove-value': + return `option ${value}, deselected.`; + case 'select-option': + return `option ${value}, selected.`; + } +}; + +export const valueFocusAriaMessage = ({ focusedValue, getOptionLabel, selectValue }) => `value ${getOptionLabel(focusedValue)} focused, ${selectValue.indexOf(focusedValue) + 1} of ${selectValue.length}.`; +export const optionFocusAriaMessage = ({ focusedOption, getOptionLabel, options }) => `option ${getOptionLabel(focusedOption)} focused, ${options.indexOf(focusedOption) + 1} of ${options.length}.`; +export const resultsAriaMessage = ({ inputValue, screenReaderMessage }) => `${screenReaderMessage} for search term ${inputValue}.`; diff --git a/src/primitives.js b/src/primitives.js index bb5c1bd35e..69f54d821d 100644 --- a/src/primitives.js +++ b/src/primitives.js @@ -25,17 +25,14 @@ export const Input = createPrimitive('input'); export const A11yText = (props: any) => ( Date: Fri, 22 Jun 2018 14:23:09 +1000 Subject: [PATCH 13/18] update cypress tests --- cypress/fixtures/selectors.json | 32 +++++++++++++++--------------- cypress/integration/select_spec.js | 19 +++++++++--------- docs/Tests.js | 10 ++++++++++ 3 files changed, 35 insertions(+), 26 deletions(-) diff --git a/cypress/fixtures/selectors.json b/cypress/fixtures/selectors.json index f59d049959..9e2043e0a8 100644 --- a/cypress/fixtures/selectors.json +++ b/cypress/fixtures/selectors.json @@ -2,29 +2,29 @@ "clearValues": ".react-select__clear-indicator", "disabledCheckbox": "#cypress-container-single #cypress-single__disabled-checkbox", "firstMultiValueRemove": - "#cypress-multi .react-select__multi-value__remove:first", + "#multi-select .react-select__multi-value__remove:first", "groupColor": "#cypress-container-single #cypress-single-grouped .react-select__group", - "menuGrouped": "#react-select-3-listbox", - "menuMulti": "#react-select-8-listbox", + "menuGrouped": "#grouped-options-single .react-select__menu", + "menuMulti": "#multi-select .react-select__menu", "menuOption": "[role='option']", - "menuSingle": "#react-select-2-listbox", - "multiSelectDefaultValues": "#cypress-multi .react-select__multi-value", - "multiSelectInput": "#react-select-8-input", + "menuSingle": "#basic-select-single .react-select__menu", + "multiSelectDefaultValues": "#multi-select .react-select__multi-value", + "multiSelectInput": "#react-select-multi-select-input", "noOptionsValue": ".react-select__menu-notice--no-options", - "placeHolderGrouped": "#cypress-container-single #cypress-single-grouped .react-select__placeholder", - "placeHolderMulti": "#cypress-multi .react-select__placeholder", - "placeHolderSingle": "#cypress-container-single #cypress-single .react-select__placeholder", + "placeHolderGrouped": "#grouped-options-single .react-select__placeholder", + "placeHolderMulti": "#multi-select .react-select__placeholder", + "placeHolderSingle": "#basic-select-single .react-select__placeholder", "singleGroupedInputValue": - "#cypress-container-single #cypress-single-grouped .react-select__single-value", + "#grouped-options-single .react-select__single-value", "singleInputValue": ".react-select__single-value", "singleSelectDefaultValues": "#cypress-container-single .react-select__single-value", - "singleSelectFirstValue": "#cypress-container-single #cypress-single .react-select__single-value", + "singleSelectFirstValue": "#basic-select-single .react-select__single-value", "singleSelectGroupedInput": - "#cypress-container-single #cypress-single-grouped .react-select__input input", - "singleSelectSingleInput": "#react-select-2-input", + "#grouped-options-single .react-select__input input", + "singleSelectSingleInput": "#react-select-basic-select-single-input", "toggleMenus": ".react-select__dropdown-indicator", "toggleMenuGrouped": - "#cypress-container-single #cypress-single-grouped .react-select__dropdown-indicator", - "toggleMenuMulti": "#cypress-multi .react-select__dropdown-indicator", - "toggleMenuSingle": "#cypress-container-single #cypress-single .react-select__dropdown-indicator" + "#grouped-options-single .react-select__dropdown-indicator", + "toggleMenuMulti": "#multi-select .react-select__dropdown-indicator", + "toggleMenuSingle": "#basic-select-single .react-select__dropdown-indicator" } diff --git a/cypress/integration/select_spec.js b/cypress/integration/select_spec.js index 465759d8b5..afa71861f7 100644 --- a/cypress/integration/select_spec.js +++ b/cypress/integration/select_spec.js @@ -37,12 +37,12 @@ describe('New Select', function() { cy .get(selector.toggleMenus) .should('have.length', 8) - .get(selector.singleSelectSingleInput) - .should('have.attr', 'aria-expanded', 'false') + .get(selector.menuSingle) + .should('not.exist') .get(selector.toggleMenuSingle) .click() - .get(selector.singleSelectSingleInput) - .should('have.attr', 'aria-expanded', 'true') + .get(selector.menuSingle) + .should('exist') .get(selector.menuSingle) .should('be.visible') .get(selector.menuOption) @@ -90,8 +90,7 @@ describe('New Select', function() { .get(selector.menuGrouped) .should('be.visible') .get(selector.groupColor) - .should('be.visible') - .and('have.attr', 'aria-expanded', 'true'); + .should('be.visible'); }); it( 'Should not display the options menu when touched and dragged ' + view, @@ -184,12 +183,12 @@ describe('New Select', function() { 'Should select different options using - click and enter ' + view, function() { cy - .get(selector.multiSelectInput) - .should('have.attr', 'aria-expanded', 'false') + .get(selector.menuMulti) + .should('not.exist') .get(selector.toggleMenuMulti) .click() - .get(selector.multiSelectInput) - .should('have.attr', 'aria-expanded', 'true') + .get(selector.menuMulti) + .should('exist') .get(selector.menuMulti) .should('be.visible') .get(selector.menuOption) diff --git a/docs/Tests.js b/docs/Tests.js index e8c5f0b478..56d44a2587 100644 --- a/docs/Tests.js +++ b/docs/Tests.js @@ -66,6 +66,8 @@ class TestSuite extends Component {
{

Grouped

{
{'overflow: hidden; position: absolute;'}
Multi Select
Date: Fri, 22 Jun 2018 14:38:07 +1000 Subject: [PATCH 14/18] added null checks for values passed into announceStatus --- src/Select.js | 4 ++-- src/accessibility/index.js | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Select.js b/src/Select.js index 18cfd231cd..8dd63459df 100644 --- a/src/Select.js +++ b/src/Select.js @@ -574,7 +574,7 @@ export default class Select extends Component { action: 'remove-value', removedValue, }); - this.announceAriaLiveSelection({ event: 'remove-value', context: { value: this.getOptionLabel(removedValue) } }); + this.announceAriaLiveSelection({ event: 'remove-value', context: { value: removedValue ? this.getOptionLabel(removedValue) : undefined } }); this.focusInput(); }; clearValue = () => { @@ -585,7 +585,7 @@ export default class Select extends Component { const { onChange } = this.props; const { selectValue } = this.state; const lastSelectedValue = selectValue[selectValue.length - 1]; - this.announceAriaLiveSelection({ event: 'pop-value', context: { value: this.getOptionLabel(lastSelectedValue) } }); + this.announceAriaLiveSelection({ event: 'pop-value', context: { value: lastSelectedValue ? this.getOptionLabel(lastSelectedValue) : undefined } }); onChange(selectValue.slice(0, selectValue.length - 1), { action: 'pop-value', removedValue: lastSelectedValue, diff --git a/src/accessibility/index.js b/src/accessibility/index.js index 8d86bbe218..7c43f893fd 100644 --- a/src/accessibility/index.js +++ b/src/accessibility/index.js @@ -15,6 +15,7 @@ export const instructionsAriaMessage = (event, context?: InstructionsContext = { export const valueEventAriaMessage = (event, context: ValueEventContext) => { const { value } = context; + if (!value) return; switch (event) { case 'deselect-option': case 'pop-value': From 8f5cb3a586f8aca4d496b6bd2fedfcd68742e408 Mon Sep 17 00:00:00 2001 From: Charles Lee Date: Fri, 22 Jun 2018 14:39:12 +1000 Subject: [PATCH 15/18] update snapshots --- .../__snapshots__/Async.test.js.snap | 40 ++----------------- .../__snapshots__/AsyncCreatable.test.js.snap | 40 ++----------------- .../__snapshots__/Select.test.js.snap | 18 +-------- 3 files changed, 7 insertions(+), 91 deletions(-) diff --git a/src/__tests__/__snapshots__/Async.test.js.snap b/src/__tests__/__snapshots__/Async.test.js.snap index ca9cc648c6..5b228c6010 100644 --- a/src/__tests__/__snapshots__/Async.test.js.snap +++ b/src/__tests__/__snapshots__/Async.test.js.snap @@ -138,20 +138,6 @@ exports[`defaults - snapshot 1`] = ` className="css-10nd86i" onKeyDown={[Function]} > - - - 0 results available. - -