Merge pull request #3497 from matrix-org/t3chguy/autocomplete_a11y

Make Autocomplete more accessible to screen reader users
This commit is contained in:
Michael Telatynski 2019-10-01 12:07:39 +01:00 committed by GitHub
commit 2d6461d376
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 67 additions and 29 deletions

View file

@ -105,8 +105,14 @@ export default class CommunityProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"> return (
{ completions } <div
</div>; className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
role="listbox"
aria-label={_t("Community Autocomplete")}
>
{ completions }
</div>
);
} }
} }

View file

@ -60,7 +60,7 @@ export class PillCompletion extends React.Component {
...restProps ...restProps
} = this.props; } = this.props;
return ( return (
<div className={classNames('mx_Autocomplete_Completion_pill', className)} {...restProps}> <div className={classNames('mx_Autocomplete_Completion_pill', className)} role="option" {...restProps}>
{ initialComponent } { initialComponent }
<span className="mx_Autocomplete_Completion_title">{ title }</span> <span className="mx_Autocomplete_Completion_title">{ title }</span>
<span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span> <span className="mx_Autocomplete_Completion_subtitle">{ subtitle }</span>

View file

@ -116,7 +116,9 @@ export default class EmojiProvider extends AutocompleteProvider {
return { return {
completion: unicode, completion: unicode,
component: ( component: (
<PillCompletion title={shortname} initialComponent={<span style={{maxWidth: '1em'}}>{ unicode }</span>} /> <PillCompletion title={shortname} aria-label={unicode} initialComponent={
<span style={{maxWidth: '1em'}}>{ unicode }</span>
} />
), ),
range, range,
}; };
@ -130,8 +132,10 @@ export default class EmojiProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill"> return (
{ completions } <div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("Emoji Autocomplete")}>
</div>; { completions }
</div>
);
} }
} }

View file

@ -58,8 +58,14 @@ export default class NotifProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"> return (
{ completions } <div
</div>; className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
role="listbox"
aria-label={_t("Notification Autocomplete")}
>
{ completions }
</div>
);
} }
} }

View file

@ -109,8 +109,14 @@ export default class RoomProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"> return (
{ completions } <div
</div>; className="mx_Autocomplete_Completion_container_pill mx_Autocomplete_Completion_container_truncate"
role="listbox"
aria-label={_t("Room Autocomplete")}
>
{ completions }
</div>
);
} }
} }

View file

@ -164,9 +164,11 @@ export default class UserProvider extends AutocompleteProvider {
} }
renderCompletions(completions: [React.Component]): ?React.Component { renderCompletions(completions: [React.Component]): ?React.Component {
return <div className="mx_Autocomplete_Completion_container_pill"> return (
{ completions } <div className="mx_Autocomplete_Completion_container_pill" role="listbox" aria-label={_t("User Autocomplete")}>
</div>; { completions }
</div>
);
} }
shouldForceComplete(): boolean { shouldForceComplete(): boolean {

View file

@ -20,18 +20,17 @@ import ReactDOM from 'react-dom';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
import classNames from 'classnames'; import classNames from 'classnames';
import flatMap from 'lodash/flatMap'; import flatMap from 'lodash/flatMap';
import isEqual from 'lodash/isEqual';
import sdk from '../../../index';
import type {Completion} from '../../../autocomplete/Autocompleter'; import type {Completion} from '../../../autocomplete/Autocompleter';
import Promise from 'bluebird'; import Promise from 'bluebird';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import {getCompletions} from '../../../autocomplete/Autocompleter';
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import Autocompleter from '../../../autocomplete/Autocompleter'; import Autocompleter from '../../../autocomplete/Autocompleter';
const COMPOSER_SELECTED = 0; const COMPOSER_SELECTED = 0;
export const generateCompletionDomId = (number) => `mx_Autocomplete_Completion_${number}`;
export default class Autocomplete extends React.Component { export default class Autocomplete extends React.Component {
constructor(props) { constructor(props) {
super(props); super(props);
@ -224,7 +223,7 @@ export default class Autocomplete extends React.Component {
setSelection(selectionOffset: number) { setSelection(selectionOffset: number) {
this.setState({selectionOffset, hide: false}); this.setState({selectionOffset, hide: false});
if (this.props.onSelectionChange) { if (this.props.onSelectionChange) {
this.props.onSelectionChange(this.state.completionList[selectionOffset - 1]); this.props.onSelectionChange(this.state.completionList[selectionOffset - 1], selectionOffset - 1);
} }
} }
@ -250,9 +249,8 @@ export default class Autocomplete extends React.Component {
let position = 1; let position = 1;
const renderedCompletions = this.state.completions.map((completionResult, i) => { const renderedCompletions = this.state.completions.map((completionResult, i) => {
const completions = completionResult.completions.map((completion, i) => { const completions = completionResult.completions.map((completion, i) => {
const className = classNames('mx_Autocomplete_Completion', { const selected = position === this.state.selectionOffset;
'selected': position === this.state.selectionOffset, const className = classNames('mx_Autocomplete_Completion', {selected});
});
const componentPosition = position; const componentPosition = position;
position++; position++;
@ -261,10 +259,12 @@ export default class Autocomplete extends React.Component {
}; };
return React.cloneElement(completion.component, { return React.cloneElement(completion.component, {
key: i, "key": i,
ref: `completion${position - 1}`, "ref": `completion${componentPosition}`,
"id": generateCompletionDomId(componentPosition - 1), // 0 index the completion IDs
className, className,
onClick, onClick,
"aria-selected": selected,
}); });
}); });

View file

@ -28,7 +28,7 @@ import {
replaceRangeAndMoveCaret, replaceRangeAndMoveCaret,
} from '../../../editor/operations'; } from '../../../editor/operations';
import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom'; import {getCaretOffsetAndText, getRangeForSelection} from '../../../editor/dom';
import Autocomplete from '../rooms/Autocomplete'; import Autocomplete, {generateCompletionDomId} from '../rooms/Autocomplete';
import {autoCompleteCreator} from '../../../editor/parts'; import {autoCompleteCreator} from '../../../editor/parts';
import {parsePlainTextMessage} from '../../../editor/deserialize'; import {parsePlainTextMessage} from '../../../editor/deserialize';
import {renderModel} from '../../../editor/render'; import {renderModel} from '../../../editor/render';
@ -432,8 +432,9 @@ export default class BasicMessageEditor extends React.Component {
this.props.model.autoComplete.onComponentConfirm(completion); this.props.model.autoComplete.onComponentConfirm(completion);
} }
_onAutoCompleteSelectionChange = (completion) => { _onAutoCompleteSelectionChange = (completion, completionIndex) => {
this.props.model.autoComplete.onComponentSelectionChange(completion); this.props.model.autoComplete.onComponentSelectionChange(completion);
this.setState({completionIndex});
} }
componentWillUnmount() { componentWillUnmount() {
@ -535,6 +536,8 @@ export default class BasicMessageEditor extends React.Component {
quote: ctrlShortcutLabel(">"), quote: ctrlShortcutLabel(">"),
}; };
const {completionIndex} = this.state;
return (<div className={classes}> return (<div className={classes}>
{ autoComplete } { autoComplete }
<MessageComposerFormatBar ref={ref => this._formatBarRef = ref} onAction={this._onFormatAction} shortcuts={shortcuts} /> <MessageComposerFormatBar ref={ref => this._formatBarRef = ref} onAction={this._onFormatAction} shortcuts={shortcuts} />
@ -548,7 +551,13 @@ export default class BasicMessageEditor extends React.Component {
onKeyDown={this._onKeyDown} onKeyDown={this._onKeyDown}
ref={ref => this._editorRef = ref} ref={ref => this._editorRef = ref}
aria-label={this.props.label} aria-label={this.props.label}
></div> role="textbox"
aria-multiline="true"
aria-autocomplete="both"
aria-haspopup="listbox"
aria-expanded={Boolean(this.state.autoComplete)}
aria-activedescendant={completionIndex >= 0 ? generateCompletionDomId(completionIndex) : undefined}
/>
</div>); </div>);
} }

View file

@ -1726,11 +1726,16 @@
"Clear personal data": "Clear personal data", "Clear personal data": "Clear personal data",
"Warning: Your personal data (including encryption keys) is still stored on this device. Clear it if you're finished using this device, or want to sign in to another account.": "Warning: Your personal data (including encryption keys) is still stored on this device. Clear it if you're finished using this device, or want to sign in to another account.", "Warning: Your personal data (including encryption keys) is still stored on this device. Clear it if you're finished using this device, or want to sign in to another account.": "Warning: Your personal data (including encryption keys) is still stored on this device. Clear it if you're finished using this device, or want to sign in to another account.",
"Commands": "Commands", "Commands": "Commands",
"Community Autocomplete": "Community Autocomplete",
"Results from DuckDuckGo": "Results from DuckDuckGo", "Results from DuckDuckGo": "Results from DuckDuckGo",
"Emoji": "Emoji", "Emoji": "Emoji",
"Emoji Autocomplete": "Emoji Autocomplete",
"Notify the whole room": "Notify the whole room", "Notify the whole room": "Notify the whole room",
"Room Notification": "Room Notification", "Room Notification": "Room Notification",
"Notification Autocomplete": "Notification Autocomplete",
"Room Autocomplete": "Room Autocomplete",
"Users": "Users", "Users": "Users",
"User Autocomplete": "User Autocomplete",
"unknown device": "unknown device", "unknown device": "unknown device",
"NOT verified": "NOT verified", "NOT verified": "NOT verified",
"Blacklisted": "Blacklisted", "Blacklisted": "Blacklisted",