From 1d012fc0f7733f75e559143711a0f68c43229914 Mon Sep 17 00:00:00 2001 From: Dave Brotherstone Date: Mon, 9 May 2016 08:28:26 +0200 Subject: [PATCH] Add support for assistive technologies This adds support for aria. Some minor changes to the markup have been made in order to get a better experience with a screen reader. This should be regarded as a first step, and there will almost certainly be more things we can do to improve this further, and fixes for other screenreaders etc. --- less/control.less | 17 ++- package.json | 2 +- scss/control.scss | 4 + src/Option.js | 8 +- src/Select.js | 157 ++++++++++++++++++------- src/Value.js | 4 +- test/Select-test.js | 277 +++++++++++++++++++++++++++++++++----------- wallaby.js | 7 +- 8 files changed, 357 insertions(+), 119 deletions(-) diff --git a/less/control.less b/less/control.less index 9e74a778c7..ec8ec88619 100644 --- a/less/control.less +++ b/less/control.less @@ -112,8 +112,8 @@ white-space: nowrap; } -.has-value.Select--single > .Select-control > .Select-value, -.has-value.is-pseudo-focused.Select--single > .Select-control > .Select-value { +.has-value.Select--single > .Select-control .Select-value, +.has-value.is-pseudo-focused.Select--single > .Select-control .Select-value { .Select-value-label { color: @select-text-color; } @@ -240,8 +240,17 @@ border-top-color: @select-arrow-color-hover; } - - +.Select--multi .Select-multi-value-wrapper { + display: inline-block; +} +.Select .Select-aria-only { + display: inline-block; + height: 1px; + width: 1px; + margin: -1px; + clip: rect(0,0,0,0); + overflow: hidden; +} // Animation // ------------------------------ diff --git a/package.json b/package.json index dfaf34dc0b..5dae3f4e83 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ "sinon": "^1.17.3", "unexpected": "^10.13.2", "unexpected-dom": "^3.1.0", - "unexpected-react": "^3.1.3", + "unexpected-react": "^3.2.3", "unexpected-sinon": "^10.2.0" }, "peerDependencies": { diff --git a/scss/control.scss b/scss/control.scss index f598ad17d9..7388ed1d0b 100644 --- a/scss/control.scss +++ b/scss/control.scss @@ -200,6 +200,10 @@ width: $select-clear-width; } +.Select--multi .Select-multi-value-wrapper { + display: inline-block; +} + // arrow indicator diff --git a/src/Option.js b/src/Option.js index b024f64da8..3ab7b481c2 100644 --- a/src/Option.js +++ b/src/Option.js @@ -5,6 +5,7 @@ const Option = React.createClass({ propTypes: { children: React.PropTypes.node, className: React.PropTypes.string, // className (based on mouse position) + instancePrefix: React.PropTypes.string.isRequired, // unique prefix for the ids (used for aria) isDisabled: React.PropTypes.bool, // the option is disabled isFocused: React.PropTypes.bool, // the option is focused isSelected: React.PropTypes.bool, // the option is selected @@ -12,6 +13,7 @@ const Option = React.createClass({ onSelect: React.PropTypes.func, // method to handle click on option element onUnfocus: React.PropTypes.func, // method to handle mouseLeave on option element option: React.PropTypes.object.isRequired, // object that is base for that option + optionIndex: React.PropTypes.number, // index of the option, used to generate unique ids for aria }, blockEvent (event) { event.preventDefault(); @@ -64,7 +66,7 @@ const Option = React.createClass({ } }, render () { - var { option } = this.props; + var { option, instancePrefix, optionIndex } = this.props; var className = classNames(this.props.className, option.className); return option.disabled ? ( @@ -76,12 +78,14 @@ const Option = React.createClass({ ) : (
{this.props.children}
diff --git a/src/Select.js b/src/Select.js index 4f4f3a1cfa..3ab8ca4947 100644 --- a/src/Select.js +++ b/src/Select.js @@ -22,6 +22,8 @@ const stringOrNode = React.PropTypes.oneOfType([ React.PropTypes.node ]); +let instanceId = 1; + const Select = React.createClass({ displayName: 'Select', @@ -29,10 +31,14 @@ const Select = React.createClass({ propTypes: { addLabelText: React.PropTypes.string, // placeholder displayed when you want to add a label on a multi-value input allowCreate: React.PropTypes.bool, // whether to allow creation of new entries + 'aria-label': React.PropTypes.string, // Aria label (for assistive tech) + 'aria-labelledby': React.PropTypes.string, // HTML ID of an element that should be used as the label (for assistive tech) autoBlur: React.PropTypes.bool, // automatically blur the component when an option is selected autofocus: React.PropTypes.bool, // autofocus the component on mount autosize: React.PropTypes.bool, // whether to enable autosizing or not backspaceRemoves: React.PropTypes.bool, // whether backspace removes an item if there is no text input + backspaceToRemoveMessage: React.PropTypes.string, // Message to use for screenreaders to press backspace to remove the current item - + // {label} is replaced with the item label className: React.PropTypes.string, // className for the outer element clearAllText: stringOrNode, // title for the "clear" control when multi: true clearValueText: stringOrNode, // title for the "clear" control @@ -98,6 +104,7 @@ const Select = React.createClass({ autosize: true, allowCreate: false, backspaceRemoves: true, + backspaceToRemoveMessage: 'Press backspace to remove {label}', clearable: true, clearAllText: 'Clear all', clearValueText: 'Clear value', @@ -143,6 +150,7 @@ const Select = React.createClass({ }, componentWillMount () { + this._instancePrefix = 'react-select-' + (++instanceId) + '-'; const valueArray = this.getValueArray(this.props.value); if (this.props.required) { @@ -494,6 +502,7 @@ const Select = React.createClass({ this.addValue(value); this.setState({ inputValue: '', + focusedIndex: null }); } else { this.setValue(value); @@ -553,36 +562,43 @@ const Select = React.createClass({ }, focusAdjacentOption (dir) { - var options = this._visibleOptions.filter(i => !i.disabled); + var options = this._visibleOptions + .map((option, index) => ({ option, index })) + .filter(option => !option.option.disabled); this._scrollToFocusedOptionOnUpdate = true; if (!this.state.isOpen) { this.setState({ isOpen: true, inputValue: '', - focusedOption: this._focusedOption || options[dir === 'next' ? 0 : options.length - 1] + focusedOption: this._focusedOption || options[dir === 'next' ? 0 : options.length - 1].option }); return; } if (!options.length) return; var focusedIndex = -1; for (var i = 0; i < options.length; i++) { - if (this._focusedOption === options[i]) { + if (this._focusedOption === options[i].option) { focusedIndex = i; break; } } - var focusedOption = options[0]; - if (dir === 'next' && focusedIndex > -1 && focusedIndex < options.length - 1) { - focusedOption = options[focusedIndex + 1]; + if (dir === 'next' && focusedIndex !== -1 ) { + focusedIndex = (focusedIndex + 1) % options.length; } else if (dir === 'previous') { if (focusedIndex > 0) { - focusedOption = options[focusedIndex - 1]; + focusedIndex = focusedIndex - 1; } else { - focusedOption = options[options.length - 1]; + focusedIndex = options.length - 1; } } + + if (focusedIndex === -1) { + focusedIndex = 0; + } + this.setState({ - focusedOption: focusedOption + focusedIndex: options[focusedIndex].index, + focusedOption: options[focusedIndex].option }); }, @@ -615,13 +631,16 @@ const Select = React.createClass({ return valueArray.map((value, i) => { return ( + > {renderLabel(value)} +   ); }); @@ -629,61 +648,78 @@ const Select = React.createClass({ if (isOpen) onClick = null; return ( + > {renderLabel(valueArray[0])} ); } }, - renderInput (valueArray) { + renderInput (valueArray, focusedOptionIndex) { if (this.props.inputRenderer) { return this.props.inputRenderer(); } else { var className = classNames('Select-input', this.props.inputProps.className); + const isOpen = !!this.state.isOpen; + + const ariaOwns = classNames({ + [this._instancePrefix + '-list']: isOpen, + [this._instancePrefix + '-backspace-remove-message']: this.props.multi && + !this.props.disabled && + this.state.isFocused && + !this.state.inputValue + }); + + // TODO: Check how this project includes Object.assign() + const inputProps = Object.assign({}, this.props.inputProps, { + role: 'combobox', + 'aria-expanded': '' + isOpen, + 'aria-owns': ariaOwns, + 'aria-haspopup': '' + isOpen, + 'aria-activedescendant': isOpen ? this._instancePrefix + '-option-' + focusedOptionIndex : this._instancePrefix + '-value', + 'aria-labelledby': this.props['aria-labelledby'], + 'aria-label': this.props['aria-label'], + className: className, + tabIndex: this.props.tabIndex, + onBlur: this.handleInputBlur, + onChange: this.handleInputChange, + onFocus: this.handleInputFocus, + ref: 'input', + required: this.state.required, + value: this.state.inputValue + }); + if (this.props.disabled || !this.props.searchable) { return (
); } + if (this.props.autosize) { return ( - + ); } return (
- +
); } @@ -779,6 +815,8 @@ const Select = React.createClass({ return (