Merge branch 'accessibility' into 'develop'

Accessibility improvements

See merge request soapbox-pub/soapbox-fe!697
This commit is contained in:
Alex Gleason 2021-08-28 18:26:33 +00:00
commit e652de227c
21 changed files with 222 additions and 77 deletions

View File

@ -9,8 +9,9 @@ export function openModal(type, props) {
};
}
export function closeModal() {
export function closeModal(type) {
return {
type: MODAL_CLOSE,
modalType: type,
};
}

View File

@ -66,7 +66,7 @@ export default class Button extends React.PureComponent {
if (this.props.to) {
return (
<Link to={this.props.to}>
<Link to={this.props.to} tabIndex={-1}>
{btn}
</Link>
);

View File

@ -45,7 +45,9 @@ class DropdownMenu extends React.PureComponent {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('keydown', this.handleKeyDown, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem && this.props.openedViaKeyboard) this.focusedItem.focus();
if (this.focusedItem && this.props.openedViaKeyboard) {
this.focusedItem.focus({ preventScroll: true });
}
this.setState({ mounted: true });
}
@ -66,38 +68,42 @@ class DropdownMenu extends React.PureComponent {
handleKeyDown = e => {
const items = Array.from(this.node.getElementsByTagName('a'));
const index = items.indexOf(document.activeElement);
let element;
let element = null;
switch(e.key) {
case 'ArrowDown':
element = items[index+1];
if (element) {
element.focus();
}
element = items[index+1] || items[0];
break;
case 'ArrowUp':
element = items[index-1];
if (element) {
element.focus();
element = items[index-1] || items[items.length-1];
break;
case 'Tab':
if (e.shiftKey) {
element = items[index-1] || items[items.length-1];
} else {
element = items[index+1] || items[0];
}
break;
case 'Home':
element = items[0];
if (element) {
element.focus();
}
break;
case 'End':
element = items[items.length-1];
break;
case 'Escape':
this.props.onClose();
break;
}
if (element) {
element.focus();
}
break;
e.preventDefault();
e.stopPropagation();
}
}
handleItemKeyDown = e => {
if (e.key === 'Enter') {
handleItemKeyPress = e => {
if (e.key === 'Enter' || e.key === ' ') {
this.handleClick(e);
}
}
@ -151,7 +157,7 @@ class DropdownMenu extends React.PureComponent {
ref={i === 0 ? this.setFocusRef : null}
onClick={this.handleClick}
onAuxClick={this.handleAuxClick}
onKeyDown={this.handleItemKeyDown}
onKeyPress={this.handleItemKeyPress}
data-index={i}
target={newTab ? '_blank' : null}
data-method={isLogout ? 'delete' : null}
@ -226,19 +232,36 @@ export default class Dropdown extends React.PureComponent {
}
handleClose = () => {
if (this.activeElement) {
this.activeElement.focus();
this.activeElement = null;
}
this.props.onClose(this.state.id);
}
handleKeyDown = e => {
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
}
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
}
handleKeyPress = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleClick(e);
e.stopPropagation();
e.preventDefault();
break;
case 'Escape':
this.handleClose();
break;
}
}
@ -276,7 +299,7 @@ export default class Dropdown extends React.PureComponent {
const open = this.state.id === openDropdownId;
return (
<div onKeyDown={this.handleKeyDown}>
<div>
<IconButton
icon={icon}
title={title}
@ -285,6 +308,9 @@ export default class Dropdown extends React.PureComponent {
size={size}
ref={this.setTargetRef}
onClick={this.handleClick}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
onKeyPress={this.handleKeyPress}
/>
<Overlay show={open} placement={dropdownPlacement} target={this.findTarget}>

View File

@ -13,8 +13,10 @@ export default class IconButton extends React.PureComponent {
title: PropTypes.string.isRequired,
icon: PropTypes.string.isRequired,
onClick: PropTypes.func,
onMouseDown: PropTypes.func,
onKeyUp: PropTypes.func,
onKeyDown: PropTypes.func,
onKeyPress: PropTypes.func,
onMouseEnter: PropTypes.func,
onMouseLeave: PropTypes.func,
size: PropTypes.number,
@ -54,6 +56,30 @@ export default class IconButton extends React.PureComponent {
}
}
handleMouseDown = (e) => {
if (!this.props.disabled && this.props.onMouseDown) {
this.props.onMouseDown(e);
}
}
handleKeyDown = (e) => {
if (!this.props.disabled && this.props.onKeyDown) {
this.props.onKeyDown(e);
}
}
handleKeyUp = (e) => {
if (!this.props.disabled && this.props.onKeyUp) {
this.props.onKeyUp(e);
}
}
handleKeyPress = (e) => {
if (this.props.onKeyPress && !this.props.disabled) {
this.props.onKeyPress(e);
}
}
render() {
const style = {
fontSize: `${this.props.size}px`,
@ -98,8 +124,10 @@ export default class IconButton extends React.PureComponent {
title={title}
className={classes}
onClick={this.handleClick}
onKeyUp={this.props.onKeyUp}
onKeyDown={this.props.onKeyDown}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onKeyPress={this.handleKeyPress}
onMouseEnter={this.props.onMouseEnter}
onMouseLeave={this.props.onMouseLeave}
tabIndex={tabIndex}
@ -125,8 +153,10 @@ export default class IconButton extends React.PureComponent {
title={title}
className={classes}
onClick={this.handleClick}
onKeyUp={this.props.onKeyUp}
onKeyDown={this.props.onKeyDown}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleKeyDown}
onKeyUp={this.handleKeyUp}
onKeyPress={this.handleKeyPress}
onMouseEnter={this.props.onMouseEnter}
onMouseLeave={this.props.onMouseLeave}
tabIndex={tabIndex}

View File

@ -1,5 +1,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import 'wicg-inert';
import { injectIntl, FormattedMessage, defineMessages } from 'react-intl';
import { connect } from 'react-redux';
import { openModal } from '../actions/modal';
@ -74,8 +75,31 @@ class ModalRoot extends React.PureComponent {
}
};
handleKeyDown = (e) => {
if (e.key === 'Tab') {
const focusable = Array.from(this.node.querySelectorAll('button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])')).filter((x) => window.getComputedStyle(x).display !== 'none');
const index = focusable.indexOf(e.target);
let element;
if (e.shiftKey) {
element = focusable[index - 1] || focusable[focusable.length - 1];
} else {
element = focusable[index + 1] || focusable[0];
}
if (element) {
element.focus();
e.stopPropagation();
e.preventDefault();
}
}
}
componentDidMount() {
window.addEventListener('keyup', this.handleKeyUp, false);
window.addEventListener('keydown', this.handleKeyDown, false);
}
componentDidUpdate(prevProps) {
@ -101,6 +125,7 @@ class ModalRoot extends React.PureComponent {
componentWillUnmount() {
window.removeEventListener('keyup', this.handleKeyUp);
window.removeEventListener('keydown', this.handleKeyDown);
}
getSiblings = () => {

View File

@ -213,6 +213,21 @@ class Status extends ImmutablePureComponent {
this.props.OnOpenAudio(media, startTime);
}
handleHotkeyOpenMedia = e => {
const { onOpenMedia, onOpenVideo } = this.props;
const status = this._properStatus();
e.preventDefault();
if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
onOpenVideo(status.getIn(['media_attachments', 0]), 0);
} else {
onOpenMedia(status.get('media_attachments'), 0);
}
}
}
handleHotkeyReply = e => {
e.preventDefault();
this.props.onReply(this._properStatus(), this.context.router.history);
@ -461,6 +476,7 @@ class Status extends ImmutablePureComponent {
moveDown: this.handleHotkeyMoveDown,
toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
openMedia: this.handleHotkeyOpenMedia,
react: this.handleHotkeyReact,
};

View File

@ -20,7 +20,7 @@ const mapDispatchToProps = (dispatch, { status, items }) => ({
}) : openDropdownMenu(id, dropdownPlacement, keyboard));
},
onClose(id) {
dispatch(closeModal());
dispatch(closeModal('ACTIONS'));
dispatch(closeDropdownMenu(id));
},
});

View File

@ -15,6 +15,8 @@ import UI from '../features/ui';
// import Introduction from '../features/introduction';
import { preload } from '../actions/preload';
import { IntlProvider } from 'react-intl';
import { previewState as previewMediaState } from 'soapbox/features/ui/components/media_modal';
import { previewState as previewVideoState } from 'soapbox/features/ui/components/video_modal';
import ErrorBoundary from '../components/error_boundary';
import { fetchInstance } from 'soapbox/actions/instance';
import { fetchSoapboxConfig } from 'soapbox/actions/soapbox';
@ -104,6 +106,10 @@ class SoapboxMount extends React.PureComponent {
this.maybeUpdateMessages(prevProps);
}
shouldUpdateScroll(_, { location }) {
return location.state !== previewMediaState && location.state !== previewVideoState;
}
render() {
const { me, themeCss, locale, customCss } = this.props;
if (me === null) return null;
@ -137,7 +143,7 @@ class SoapboxMount extends React.PureComponent {
<meta name='theme-color' content={this.props.brandColor} />
</Helmet>
<BrowserRouter>
<ScrollContext>
<ScrollContext shouldUpdateScroll={this.shouldUpdateScroll}>
<Switch>
{!me && <Route exact path='/' component={PublicLayout} />}
<Route exact path='/about/:slug?' component={PublicLayout} />

View File

@ -314,10 +314,6 @@ export default class ComposeForm extends ImmutablePureComponent {
/>
</div>
<div className='emoji-picker-wrapper'>
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
</div>
<AutosuggestTextarea
ref={(isModalOpen && shouldCondense) ? null : this.setAutosuggestTextarea}
placeholder={intl.formatMessage(messages.placeholder)}
@ -333,6 +329,7 @@ export default class ComposeForm extends ImmutablePureComponent {
onPaste={onPaste}
autoFocus={shouldAutoFocus}
>
<EmojiPickerDropdown onPickEmoji={this.handleEmojiPick} />
{
!condensed &&
<div className='compose-form__modifiers'>

View File

@ -50,7 +50,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
const index = items.findIndex(item => {
return (item.value === value);
});
let element;
let element = null;
switch(e.key) {
case 'Escape':
@ -60,33 +60,31 @@ class PrivacyDropdownMenu extends React.PureComponent {
this.handleClick(e);
break;
case 'ArrowDown':
element = this.node.childNodes[index + 1];
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
element = this.node.childNodes[index + 1] || this.node.firstChild;
break;
case 'ArrowUp':
element = this.node.childNodes[index - 1];
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
element = this.node.childNodes[index - 1] || this.node.lastChild;
break;
case 'Tab':
if (e.shiftKey) {
element = this.node.childNodes[index - 1] || this.node.lastChild;
} else {
element = this.node.childNodes[index + 1] || this.node.firstChild;
}
break;
case 'Home':
element = this.node.firstChild;
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
case 'End':
element = this.node.lastChild;
break;
}
if (element) {
element.focus();
this.props.onChange(element.getAttribute('data-index'));
}
break;
e.preventDefault();
e.stopPropagation();
}
}
@ -102,7 +100,7 @@ class PrivacyDropdownMenu extends React.PureComponent {
componentDidMount() {
document.addEventListener('click', this.handleDocumentClick, false);
document.addEventListener('touchend', this.handleDocumentClick, listenerOptions);
if (this.focusedItem) this.focusedItem.focus();
if (this.focusedItem) this.focusedItem.focus({ preventScroll: true });
this.setState({ mounted: true });
}
@ -192,6 +190,9 @@ class PrivacyDropdown extends React.PureComponent {
}
} else {
const { top } = target.getBoundingClientRect();
if (this.state.open && this.activeElement) {
this.activeElement.focus();
}
this.setState({ placement: top * 2 < innerHeight ? 'bottom' : 'top' });
this.setState({ open: !this.state.open });
}
@ -214,7 +215,25 @@ class PrivacyDropdown extends React.PureComponent {
}
}
handleMouseDown = () => {
if (!this.state.open) {
this.activeElement = document.activeElement;
}
}
handleButtonKeyDown = (e) => {
switch(e.key) {
case ' ':
case 'Enter':
this.handleMouseDown();
break;
}
}
handleClose = () => {
if (this.state.open && this.activeElement) {
this.activeElement.focus();
}
this.setState({ open: false });
}
@ -240,6 +259,8 @@ class PrivacyDropdown extends React.PureComponent {
active={open}
inverted
onClick={this.handleToggle}
onMouseDown={this.handleMouseDown}
onKeyDown={this.handleButtonKeyDown}
style={{ height: null, lineHeight: '27px' }}
/>
</div>

View File

@ -263,6 +263,21 @@ class Status extends ImmutablePureComponent {
this.props.dispatch(openModal('VIDEO', { media, time }));
}
handleHotkeyOpenMedia = e => {
const { onOpenMedia, onOpenVideo } = this.props;
const status = this._properStatus();
e.preventDefault();
if (status.get('media_attachments').size > 0) {
if (status.getIn(['media_attachments', 0, 'type']) === 'video') {
onOpenVideo(status.getIn(['media_attachments', 0]), 0);
} else {
onOpenMedia(status.get('media_attachments'), 0);
}
}
}
handleMuteClick = (account) => {
this.props.dispatch(initMuteModal(account));
}
@ -548,6 +563,7 @@ class Status extends ImmutablePureComponent {
openProfile: this.handleHotkeyOpenProfile,
toggleHidden: this.handleHotkeyToggleHidden,
toggleSensitive: this.handleHotkeyToggleSensitive,
openMedia: this.handleHotkeyOpenMedia,
react: this.handleHotkeyReact,
};

View File

@ -62,12 +62,8 @@ class HotkeysModal extends ImmutablePureComponent {
<td><FormattedMessage id='keyboard_shortcuts.enter' defaultMessage='to open post' /></td>
</tr>
<tr>
<td><kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
</tr>
<tr>
<td><kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
<td><kbd>a</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.open_media' defaultMessage='to open media' /></td>
</tr>
</tbody>
</table>
@ -78,6 +74,14 @@ class HotkeysModal extends ImmutablePureComponent {
</tr>
</thead>
<tbody>
<tr>
<td><kbd>x</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_hidden' defaultMessage='to show/hide text behind CW' /></td>
</tr>
<tr>
<td><kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.toggle_sensitivity' defaultMessage='to show/hide media' /></td>
</tr>
<tr>
<td><kbd>up</kbd>, <kbd>k</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.up' defaultMessage='to move up in the list' /></td>
@ -106,10 +110,6 @@ class HotkeysModal extends ImmutablePureComponent {
<td><kbd>esc</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.unfocus' defaultMessage='to un-focus compose textarea/search' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></td>
</tr>
</tbody>
</table>
<table>
@ -119,6 +119,10 @@ class HotkeysModal extends ImmutablePureComponent {
</tr>
</thead>
<tbody>
<tr>
<td><kbd>g</kbd> + <kbd>h</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.home' defaultMessage='to open home timeline' /></td>
</tr>
<tr>
<td><kbd>g</kbd> + <kbd>n</kbd></td>
<td><FormattedMessage id='keyboard_shortcuts.notifications' defaultMessage='to open notifications column' /></td>

View File

@ -155,6 +155,7 @@ const keyMap = {
goToRequests: 'g r',
toggleHidden: 'x',
toggleSensitive: 'h',
openMedia: 'a',
};
class SwitchingColumnsArea extends React.PureComponent {

View File

@ -461,7 +461,7 @@ class Video extends React.PureComponent {
<div className='video-player__buttons-bar'>
<div className='video-player__buttons left'>
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' aria-label={intl.formatMessage(paused ? messages.play : messages.pause)} className='player-button' onClick={this.togglePlay} autoFocus={detailed}><Icon id={paused ? 'play' : 'pause'} fixedWidth /></button>
<button type='button' aria-label={intl.formatMessage(muted ? messages.unmute : messages.mute)} className='player-button' onClick={this.toggleMute}><Icon id={muted ? 'volume-off' : 'volume-up'} fixedWidth /></button>
<div className={classNames('video-player__volume', { active: this.state.hovered })} onMouseDown={this.handleVolumeMouseDown} ref={this.setVolumeRef}>

View File

@ -10,7 +10,7 @@ export default function modal(state = initialState, action) {
case MODAL_OPEN:
return { modalType: action.modalType, modalProps: action.modalProps };
case MODAL_CLOSE:
return initialState;
return (action.modalType === undefined || action.modalType === state.modalType) ? initialState : state;
default:
return state;
}

View File

@ -221,6 +221,7 @@
}
&:hover,
&:focus,
&.active {
border-bottom: 2px solid var(--primary-text-color);
}

View File

@ -480,7 +480,8 @@
color: var(--primary-text-color--faint);
background: transparent;
&:hover {
&:hover,
&:focus {
color: hsla(var(--primary-text-color_hsl), 0.8);
}

View File

@ -62,9 +62,8 @@
.emoji-picker-dropdown {
position: absolute;
top: 5px;
top: 10px;
right: 5px;
z-index: 1;
}
.compose-form__autosuggest-wrapper {
@ -141,7 +140,6 @@
}
}
.emoji-picker-wrapper,
.autosuggest-textarea__suggestions-wrapper {
position: relative;
height: 0;

View File

@ -43,7 +43,7 @@
&:hover,
&:active,
&:focus {
color: var(--primary-text-color--faint);
color: var(--primary-text-color);
}
&.disabled {
@ -93,7 +93,7 @@
&:hover,
&:active,
&:focus {
color: var(--primary-text-color--faint);
color: var(--primary-text-color);
transition: color 200ms ease-out;
}
@ -148,10 +148,6 @@
padding: 20px;
margin-bottom: 20px;
.emoji-picker-wrapper {
.emoji-picker-dropdown { top: 10px; }
}
.compose-form {
flex: 1 1;
padding: 0 0 0 20px !important;

View File

@ -146,7 +146,8 @@
"webpack-bundle-analyzer": "^4.0.0",
"webpack-cli": "^3.3.2",
"webpack-merge": "^5.2.0",
"websocket.js": "^0.1.12"
"websocket.js": "^0.1.12",
"wicg-inert": "^3.1.1"
},
"devDependencies": {
"axios-mock-adapter": "^1.18.1",

View File

@ -13062,6 +13062,11 @@ which@^2.0.1:
dependencies:
isexe "^2.0.0"
wicg-inert@^3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/wicg-inert/-/wicg-inert-3.1.1.tgz#b033fd4fbfb9e3fd709e5d84becbdf2e06e5c229"
integrity sha512-PhBaNh8ur9Xm4Ggy4umelwNIP6pPP1bv3EaWaKqfb/QNme2rdLjm7wIInvV4WhxVHhzA4Spgw9qNSqWtB/ca2A==
wide-align@^1.1.0:
version "1.1.3"
resolved "https://registry.yarnpkg.com/wide-align/-/wide-align-1.1.3.tgz#ae074e6bdc0c14a431e804e624549c633b000457"