Merge pull request #1652 from pvagner/dialog-a11y

Dialog a11y
This commit is contained in:
David Baker 2018-03-20 11:35:57 +00:00 committed by GitHub
commit 580b68a1b6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 205 additions and 94 deletions

View file

@ -65,6 +65,7 @@
"file-saver": "^1.3.3",
"filesize": "3.5.6",
"flux": "2.1.1",
"focus-trap-react": "^3.0.5",
"fuse.js": "^2.2.0",
"glob": "^5.0.14",
"highlight.js": "^8.9.1",

View file

@ -22,6 +22,7 @@ const ReactDOM = require('react-dom');
import PropTypes from 'prop-types';
import Analytics from './Analytics';
import sdk from './index';
import dis from './dispatcher';
const DIALOG_CONTAINER_ID = "mx_Dialog_Container";
@ -188,10 +189,22 @@ class ModalManager {
_reRender() {
if (this._modals.length == 0) {
// If there is no modal to render, make all of Riot available
// to screen reader users again
dis.dispatch({
action: 'aria_unhide_main_app',
});
ReactDOM.unmountComponentAtNode(this.getOrCreateContainer());
return;
}
// Hide the content outside the modal to screen reader users
// so they won't be able to navigate into it and act on it using
// screen reader specific features
dis.dispatch({
action: 'aria_hide_main_app',
});
const modal = this._modals[0];
const dialog = (
<div className={"mx_Dialog_wrapper " + (modal.className ? modal.className : '')}>

View file

@ -374,7 +374,7 @@ const LoggedInView = React.createClass({
}
return (
<div className='mx_MatrixChat_wrapper'>
<div className='mx_MatrixChat_wrapper' aria-hidden={this.props.hideToSRUsers}>
{ topBar }
<DragDropContext onDragEnd={this._onDragEnd}>
<div className={bodyClasses}>

View file

@ -171,6 +171,10 @@ export default React.createClass({
register_hs_url: null,
register_is_url: null,
register_id_sid: null,
// When showing Modal dialogs we need to set aria-hidden on the root app element
// and disable it when there are no dialogs
hideToSRUsers: false,
};
return s;
},
@ -608,6 +612,16 @@ export default React.createClass({
case 'send_event':
this.onSendEvent(payload.room_id, payload.event);
break;
case 'aria_hide_main_app':
this.setState({
hideToSRUsers: true,
});
break;
case 'aria_unhide_main_app':
this.setState({
hideToSRUsers: false,
});
break;
}
},

View file

@ -15,6 +15,7 @@ limitations under the License.
*/
import React from 'react';
import FocusTrap from 'focus-trap-react';
import PropTypes from 'prop-types';
import { MatrixClient } from 'matrix-js-sdk';
@ -37,9 +38,6 @@ export default React.createClass({
// onFinished callback to call when Escape is pressed
onFinished: PropTypes.func.isRequired,
// callback to call when Enter is pressed
onEnterPressed: PropTypes.func,
// called when a key is pressed
onKeyDown: PropTypes.func,
@ -52,6 +50,10 @@ export default React.createClass({
// children should be the content of the dialog
children: PropTypes.node,
// Id of content element
// If provided, this is used to add a aria-describedby attribute
contentId: React.PropTypes.string,
},
childContextTypes: {
@ -76,12 +78,6 @@ export default React.createClass({
e.stopPropagation();
e.preventDefault();
this.props.onFinished();
} else if (e.keyCode === KeyCode.ENTER) {
if (this.props.onEnterPressed) {
e.stopPropagation();
e.preventDefault();
this.props.onEnterPressed(e);
}
}
},
@ -93,17 +89,28 @@ export default React.createClass({
const TintableSvg = sdk.getComponent("elements.TintableSvg");
return (
<div onKeyDown={this._onKeyDown} className={this.props.className}>
<FocusTrap onKeyDown={this._onKeyDown}
className={this.props.className}
role="dialog"
aria-labelledby='mx_BaseDialog_title'
// This should point to a node describing the dialog.
// If we were about to completelly follow this recommendation we'd need to
// make all the components relying on BaseDialog to be aware of it.
// So instead we will use the whole content as the description.
// Description comes first and if the content contains more text,
// AT users can skip its presentation.
aria-describedby={this.props.contentId}
>
<AccessibleButton onClick={this._onCancelClick}
className="mx_Dialog_cancelButton"
>
<TintableSvg src="img/icons-close-button.svg" width="35" height="35" />
</AccessibleButton>
<div className={'mx_Dialog_title ' + this.props.titleClass}>
<div className={'mx_Dialog_title ' + this.props.titleClass} id='mx_BaseDialog_title'>
{ this.props.title }
</div>
{ this.props.children }
</div>
</FocusTrap>
);
},
});

View file

@ -129,7 +129,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
</div>
<div className={labelClasses}><i>{ _t("Start new chat") }</i></div>
</AccessibleButton>;
content = <div className="mx_Dialog_content">
content = <div className="mx_Dialog_content" id='mx_Dialog_content'>
{ _t('You already have existing direct chats with this user:') }
<div className="mx_ChatCreateOrReuseDialog_tiles">
{ this.state.tiles }
@ -147,7 +147,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
if (this.state.busyProfile) {
profile = <Spinner />;
} else if (this.state.profileError) {
profile = <div className="error">
profile = <div className="error" role="alert">
Unable to load profile information for { this.props.userId }
</div>;
} else {
@ -163,14 +163,14 @@ export default class ChatCreateOrReuseDialog extends React.Component {
</div>;
}
content = <div>
<div className="mx_Dialog_content">
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<p>
{ _t('Click on the button below to start chatting!') }
</p>
{ profile }
</div>
<DialogButtons primaryButton={_t('Start Chatting')}
onPrimaryButtonClick={this.props.onNewDMClick} />
onPrimaryButtonClick={this.props.onNewDMClick} focus="true" />
</div>;
}
@ -179,6 +179,7 @@ export default class ChatCreateOrReuseDialog extends React.Component {
<BaseDialog className='mx_ChatCreateOrReuseDialog'
onFinished={this.props.onFinished.bind(false)}
title={title}
contentId='mx_Dialog_content'
>
{ content }
</BaseDialog>

View file

@ -114,10 +114,10 @@ export default React.createClass({
return (
<BaseDialog className="mx_ConfirmUserActionDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={this.props.title}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content">
<div id="mx_Dialog_content" className="mx_Dialog_content">
<div className="mx_ConfirmUserActionDialog_avatar">
{ avatar }
</div>

View file

@ -112,7 +112,7 @@ export default React.createClass({
// XXX: We should catch errcodes and give sensible i18ned messages for them,
// rather than displaying what the server gives us, but synapse doesn't give
// any yet.
createErrorNode = <div className="error">
createErrorNode = <div className="error" role="alert">
<div>{ _t('Something went wrong whilst creating your community') }</div>
<div>{ this.state.createError.message }</div>
</div>;
@ -120,7 +120,6 @@ export default React.createClass({
return (
<BaseDialog className="mx_CreateGroupDialog" onFinished={this.props.onFinished}
onEnterPressed={this._onFormSubmit}
title={_t('Create Community')}
>
<form onSubmit={this._onFormSubmit}>

View file

@ -45,30 +45,31 @@ export default React.createClass({
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className="mx_CreateRoomDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={_t('Create Room')}
>
<div className="mx_Dialog_content">
<div className="mx_CreateRoomDialog_label">
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
</div>
<div>
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} size="64" />
</div>
<br />
<details className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ _t('Advanced options') }</summary>
<div>
<input type="checkbox" id="checkbox" ref="checkbox" defaultChecked={this.defaultNoFederate} />
<label htmlFor="checkbox">
{ _t('Block users on other matrix homeservers from joining this room') }
<br />
({ _t('This setting cannot be changed later!') })
</label>
<form onSubmit={this.onOk}>
<div className="mx_Dialog_content">
<div className="mx_CreateRoomDialog_label">
<label htmlFor="textinput"> { _t('Room name (optional)') } </label>
</div>
</details>
</div>
<div>
<input id="textinput" ref="textinput" className="mx_CreateRoomDialog_input" autoFocus={true} size="64" />
</div>
<br />
<details className="mx_CreateRoomDialog_details">
<summary className="mx_CreateRoomDialog_details_summary">{ _t('Advanced options') }</summary>
<div>
<input type="checkbox" id="checkbox" ref="checkbox" defaultChecked={this.defaultNoFederate} />
<label htmlFor="checkbox">
{ _t('Block users on other matrix homeservers from joining this room') }
<br />
({ _t('This setting cannot be changed later!') })
</label>
</div>
</details>
</div>
</form>
<DialogButtons primaryButton={_t('Create Room')}
onPrimaryButtonClick={this.onOk}
onCancel={this.onCancel} />

View file

@ -52,22 +52,18 @@ export default React.createClass({
};
},
componentDidMount: function() {
if (this.props.focus) {
this.refs.button.focus();
}
},
render: function() {
const BaseDialog = sdk.getComponent('views.dialogs.BaseDialog');
return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
title={this.props.title || _t('Error')}>
<div className="mx_Dialog_content">
title={this.props.title || _t('Error')}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ this.props.description || _t('An error has occurred.') }
</div>
<div className="mx_Dialog_buttons">
<button ref="button" className="mx_Dialog_primary" onClick={this.props.onFinished}>
<button className="mx_Dialog_primary" onClick={this.props.onFinished} autoFocus={this.props.focus}>
{ this.props.button || _t('OK') }
</button>
</div>

View file

@ -73,11 +73,12 @@ export default React.createClass({
let content;
if (this.state.authError) {
content = (
<div>
<div>{ this.state.authError.message || this.state.authError.toString() }</div>
<div id='mx_Dialog_content'>
<div role="alert">{ this.state.authError.message || this.state.authError.toString() }</div>
<br />
<AccessibleButton onClick={this._onDismissClick}
className="mx_UserSettings_button"
autoFocus="true"
>
{ _t("Dismiss") }
</AccessibleButton>
@ -85,7 +86,7 @@ export default React.createClass({
);
} else {
content = (
<div>
<div id='mx_Dialog_content'>
<InteractiveAuth ref={this._collectInteractiveAuth}
matrixClient={this.props.matrixClient}
authData={this.props.authData}
@ -100,6 +101,7 @@ export default React.createClass({
<BaseDialog className="mx_InteractiveAuthDialog"
onFinished={this.props.onFinished}
title={this.state.authError ? 'Error' : (this.props.title || _t('Authentication'))}
contentId='mx_Dialog_content'
>
{ content }
</BaseDialog>

View file

@ -126,11 +126,11 @@ export default React.createClass({
text = _t(text, {displayName: displayName});
return (
<div>
<div id='mx_Dialog_content'>
<p>{ text }</p>
<div className="mx_Dialog_buttons">
<button onClick={this._onVerifyClicked}>
<button onClick={this._onVerifyClicked} autoFocus="true">
{ _t('Start verification') }
</button>
<button onClick={this._onShareClicked}>
@ -154,7 +154,7 @@ export default React.createClass({
content = this._renderContent();
} else {
content = (
<div>
<div id='mx_Dialog_content'>
<p>{ _t('Loading device info...') }</p>
<Spinner />
</div>
@ -165,6 +165,7 @@ export default React.createClass({
<BaseDialog className='mx_KeyShareRequestDialog'
onFinished={this.props.onFinished}
title={_t('Encryption key request')}
contentId='mx_Dialog_content'
>
{ content }
</BaseDialog>

View file

@ -60,10 +60,10 @@ export default React.createClass({
}
return (
<BaseDialog className="mx_QuestionDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={this.props.title}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content">
<div className="mx_Dialog_content" id='mx_Dialog_content'>
{ this.props.description }
</div>
<DialogButtons primaryButton={this.props.button || _t('OK')}

View file

@ -30,6 +30,12 @@ export default React.createClass({
onFinished: PropTypes.func.isRequired,
},
componentDidMount: function() {
if (this.refs.bugreportLink) {
this.refs.bugreportLink.focus();
}
},
_sendBugReport: function() {
const BugReportDialog = sdk.getComponent("dialogs.BugReportDialog");
Modal.createTrackedDialog('Session Restore Error', 'Send Bug Report Dialog', BugReportDialog, {});
@ -50,16 +56,20 @@ export default React.createClass({
{ _t(
"Otherwise, <a>click here</a> to send a bug report.",
{},
{ 'a': (sub) => <a onClick={this._sendBugReport} key="bugreport" href='#'>{ sub }</a> },
{ 'a': (sub) => <a ref="bugreportLink" onClick={this._sendBugReport}
key="bugreport" href='#'>{ sub }</a> },
) }
</p>
);
}
const shouldFocusContinueButton =!(bugreport==true);
return (
<BaseDialog className="mx_ErrorDialog" onFinished={this.props.onFinished}
title={_t('Unable to restore session')}>
<div className="mx_Dialog_content">
title={_t('Unable to restore session')}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<p>{ _t("We encountered an error trying to restore your previous session. If " +
"you continue, you will need to log in again, and encrypted chat " +
"history will be unreadable.") }</p>
@ -71,7 +81,7 @@ export default React.createClass({
{ bugreport }
</div>
<DialogButtons primaryButton={_t("Continue anyway")}
onPrimaryButtonClick={this._continueClicked}
onPrimaryButtonClick={this._continueClicked} focus={shouldFocusContinueButton}
onCancel={this.props.onFinished} />
</BaseDialog>
);

View file

@ -41,9 +41,6 @@ export default React.createClass({
};
},
componentDidMount: function() {
},
onEmailAddressChanged: function(value) {
this.setState({
emailAddress: value,
@ -131,6 +128,7 @@ export default React.createClass({
const emailInput = this.state.emailBusy ? <Spinner /> : <EditableText
className="mx_SetEmailDialog_email_input"
autoFocus="true"
placeholder={_t("Email address")}
placeholderClassName="mx_SetEmailDialog_email_input_placeholder"
blurToCancel={false}
@ -140,9 +138,10 @@ export default React.createClass({
<BaseDialog className="mx_SetEmailDialog"
onFinished={this.onCancelled}
title={this.props.title}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content">
<p>
<p id='mx_Dialog_content'>
{ _t('This will allow you to reset your password and receive notifications.') }
</p>
{ emailInput }

View file

@ -235,14 +235,14 @@ export default React.createClass({
"error": Boolean(this.state.usernameError),
"success": usernameAvailable,
});
usernameIndicator = <div className={usernameIndicatorClasses}>
usernameIndicator = <div className={usernameIndicatorClasses} role="alert">
{ usernameAvailable ? _t('Username available') : this.state.usernameError }
</div>;
}
let authErrorIndicator = null;
if (this.state.authError) {
authErrorIndicator = <div className="error">
authErrorIndicator = <div className="error" role="alert">
{ this.state.authError }
</div>;
}
@ -254,8 +254,9 @@ export default React.createClass({
<BaseDialog className="mx_SetMxIdDialog"
onFinished={this.props.onFinished}
title={_t('To get started, please pick a username!')}
contentId='mx_Dialog_content'
>
<div className="mx_Dialog_content">
<div className="mx_Dialog_content" id='mx_Dialog_content'>
<div className="mx_SetMxIdDialog_input_group">
<input type="text" ref="input_value" value={this.state.username}
autoFocus={true}

View file

@ -61,17 +61,18 @@ export default React.createClass({
const DialogButtons = sdk.getComponent('views.elements.DialogButtons');
return (
<BaseDialog className="mx_TextInputDialog" onFinished={this.props.onFinished}
onEnterPressed={this.onOk}
title={this.props.title}
>
<div className="mx_Dialog_content">
<div className="mx_TextInputDialog_label">
<label htmlFor="textinput"> { this.props.description } </label>
<form onSubmit={this.onOk}>
<div className="mx_Dialog_content">
<div className="mx_TextInputDialog_label">
<label htmlFor="textinput"> { this.props.description } </label>
</div>
<div>
<input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" />
</div>
</div>
<div>
<input id="textinput" ref="textinput" className="mx_TextInputDialog_input" defaultValue={this.props.value} autoFocus={this.props.focus} size="64" />
</div>
</div>
</form>
<DialogButtons primaryButton={this.props.button}
onPrimaryButtonClick={this.onOk}
onCancel={this.onCancel} />

View file

@ -189,8 +189,9 @@ export default React.createClass({
<BaseDialog className='mx_UnknownDeviceDialog'
onFinished={this.props.onFinished}
title={_t('Room contains unknown devices')}
contentId='mx_Dialog_content'
>
<GeminiScrollbar autoshow={false} className="mx_Dialog_content">
<GeminiScrollbar autoshow={false} className="mx_Dialog_content" id='mx_Dialog_content'>
<h4>
{ _t('"%(RoomName)s" contains devices that you haven\'t seen before.', {RoomName: this.props.room.name}) }
</h4>

View file

@ -17,6 +17,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import { KeyCode } from '../../../Keyboard';
/**
* AccessibleButton is a generic wrapper for any element that should be treated
* as a button. Identifies the element as a button, setting proper tab
@ -28,8 +30,34 @@ import PropTypes from 'prop-types';
export default function AccessibleButton(props) {
const {element, onClick, children, ...restProps} = props;
restProps.onClick = onClick;
// We need to consume enter onKeyDown and space onKeyUp
// otherwise we are risking also activating other keyboard focusable elements
// that might receive focus as a result of the AccessibleButtonClick action
// It's because we are using html buttons at a few places e.g. inside dialogs
// And divs which we report as role button to assistive technologies.
// Browsers handle space and enter keypresses differently and we are only adjusting to the
// inconsistencies here
restProps.onKeyDown = function(e) {
if (e.keyCode === KeyCode.ENTER) {
e.stopPropagation();
e.preventDefault();
return onClick(e);
}
if (e.keyCode === KeyCode.SPACE) {
e.stopPropagation();
e.preventDefault();
}
};
restProps.onKeyUp = function(e) {
if (e.keyCode == 13 || e.keyCode == 32) return onClick(e);
if (e.keyCode === KeyCode.SPACE) {
e.stopPropagation();
e.preventDefault();
return onClick(e);
}
if (e.keyCode === KeyCode.ENTER) {
e.stopPropagation();
e.preventDefault();
}
};
restProps.tabIndex = restProps.tabIndex || "0";
restProps.role = "button";

View file

@ -128,12 +128,22 @@ export const PasswordAuthEntry = React.createClass({
);
}
let errorSection;
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
</div>
);
}
return (
<div>
<p>{ _t("To continue, please enter your password.") }</p>
<p>{ _t("Password:") }</p>
<form onSubmit={this._onSubmit}>
<label htmlFor="passwordField">{ _t("Password:") }</label>
<input
name="passwordField"
ref="passwordField"
className={passwordBoxClass}
onChange={this._onPasswordFieldChange}
@ -143,9 +153,7 @@ export const PasswordAuthEntry = React.createClass({
{ submitButtonOrSpinner }
</div>
</form>
<div className="error">
{ this.props.errorText }
</div>
{ errorSection }
</div>
);
},
@ -180,14 +188,22 @@ export const RecaptchaAuthEntry = React.createClass({
const CaptchaForm = sdk.getComponent("views.login.CaptchaForm");
const sitePublicKey = this.props.stageParams.public_key;
let errorSection;
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
</div>
);
}
return (
<div>
<CaptchaForm sitePublicKey={sitePublicKey}
onCaptchaResponse={this._onCaptchaResponse}
/>
<div className="error">
{ this.props.errorText }
</div>
{ errorSection }
</div>
);
},
@ -372,6 +388,14 @@ export const MsisdnAuthEntry = React.createClass({
mx_InteractiveAuthEntryComponents_msisdnSubmit: true,
mx_UserSettings_button: true, // XXX button classes
});
let errorSection;
if (this.state.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.state.errorText }
</div>
);
}
return (
<div>
<p>{ _t("A text message has been sent to %(msisdn)s",
@ -385,6 +409,7 @@ export const MsisdnAuthEntry = React.createClass({
className="mx_InteractiveAuthEntryComponents_msisdnEntry"
value={this.state.token}
onChange={this._onTokenChange}
aria-label={ _t("Code")}
/>
<br />
<input type="submit" value={_t("Submit")}
@ -392,9 +417,7 @@ export const MsisdnAuthEntry = React.createClass({
disabled={!enableSubmit}
/>
</form>
<div className="error">
{ this.state.errorText }
</div>
{errorSection}
</div>
</div>
);
@ -427,6 +450,12 @@ export const FallbackAuthEntry = React.createClass({
}
},
focus: function() {
if (this.refs.fallbackButton) {
this.refs.fallbackButton.focus();
}
},
_onShowFallbackClick: function() {
const url = this.props.matrixClient.getFallbackAuthUrl(
this.props.loginType,
@ -445,12 +474,18 @@ export const FallbackAuthEntry = React.createClass({
},
render: function() {
return (
<div>
<a onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
<div className="error">
let errorSection;
if (this.props.errorText) {
errorSection = (
<div className="error" role="alert">
{ this.props.errorText }
</div>
);
}
return (
<div>
<a ref="fallbackButton" onClick={this._onShowFallbackClick}>{ _t("Start authentication") }</a>
{errorSection}
</div>
);
},

View file

@ -531,6 +531,7 @@
"Token incorrect": "Token incorrect",
"A text message has been sent to %(msisdn)s": "A text message has been sent to %(msisdn)s",
"Please enter the code it contains:": "Please enter the code it contains:",
"Code": "Code",
"Start authentication": "Start authentication",
"powered by Matrix": "powered by Matrix",
"Username on %(hs)s": "Username on %(hs)s",