Globally improve accessibility for screen reader users (#12969)

feat(a11y): Globally improve accessibility for screen reader users
This commit is contained in:
Emmanuel Pelletier
2023-06-19 13:34:41 +02:00
committed by GitHub
parent 7538bfc713
commit 51a4e7daa3
64 changed files with 776 additions and 428 deletions

View File

@@ -1,5 +1,5 @@
import React, { ReactNode, useCallback, useContext, useEffect } from 'react';
import FocusLock from 'react-focus-lock';
import { FocusOn } from 'react-focus-on';
import { useTranslation } from 'react-i18next';
import { keyframes } from 'tss-react';
import { makeStyles } from 'tss-react/mui';
@@ -183,7 +183,7 @@ const BaseDialog = ({
<div
className = { classes.backdrop }
onClick = { onBackdropClick } />
<FocusLock
<FocusOn
className = { classes.focusLock }
returnFocus = {
@@ -196,14 +196,16 @@ const BaseDialog = ({
isElementInTheViewport
}>
<div
aria-describedby = { description }
aria-labelledby = { title ?? t(titleKey ?? '') }
aria-description = { description }
aria-label = { title ?? t(titleKey ?? '') }
aria-modal = { true }
className = { cx(classes.modal, isUnmounting && 'unmount', size, className) }
role = 'dialog'>
data-autofocus = { true }
role = 'dialog'
tabIndex = { -1 }>
{children}
</div>
</FocusLock>
</FocusOn>
</div>
);
};

View File

@@ -156,8 +156,8 @@ const Checkbox = ({
const isMobile = isMobileBrowser();
return (
<div className = { cx(styles.formControl, isMobile && 'is-mobile', className) }>
<label className = { cx(styles.activeArea, isMobile && 'is-mobile', disabled && styles.disabled) }>
<label className = { cx(styles.formControl, isMobile && 'is-mobile', className) }>
<div className = { cx(styles.activeArea, isMobile && 'is-mobile', disabled && styles.disabled) }>
<input
checked = { checked }
disabled = { disabled }
@@ -165,13 +165,14 @@ const Checkbox = ({
onChange = { onChange }
type = 'checkbox' />
<Icon
aria-hidden = { true }
className = 'checkmark'
color = { disabled ? theme.palette.icon03 : theme.palette.icon01 }
size = { 18 }
src = { IconCheck } />
</label>
<label>{label}</label>
</div>
</div>
<div>{label}</div>
</label>
);
};

View File

@@ -1,4 +1,4 @@
import React, { ReactNode } from 'react';
import React, { ReactNode, useCallback } from 'react';
import { useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
@@ -76,6 +76,9 @@ export interface IProps {
/**
* You can use this item as a tab. Defaults to button if not set.
*
* If no onClick handler is provided, we assume the context menu item is
* not interactive and no role will be set.
*/
role?: 'tab' | 'button';
@@ -179,6 +182,28 @@ const ContextMenuItem = ({
const { classes: styles, cx } = useStyles();
const _overflowDrawer: boolean = useSelector(showOverflowDrawer);
const onKeyPressHandler = useCallback(e => {
// only trigger the fallback behavior (onClick) if we dont have any explicit keyboard event handler
if (onClick && !onKeyPress && !onKeyDown && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onClick(e);
}
if (onKeyPress) {
onKeyPress(e);
}
}, [ onClick, onKeyPress, onKeyDown ]);
let tabIndex: undefined | 0 | -1;
if (role === 'tab') {
tabIndex = selected ? 0 : -1;
}
if (role === 'button' && !disabled) {
tabIndex = 0;
}
return (
<div
aria-controls = { controls }
@@ -196,12 +221,9 @@ const ContextMenuItem = ({
key = { text }
onClick = { disabled ? undefined : onClick }
onKeyDown = { disabled ? undefined : onKeyDown }
onKeyPress = { disabled ? undefined : onKeyPress }
role = { role }
tabIndex = { role === 'tab'
? selected ? 0 : -1
: disabled ? undefined : 0
}>
onKeyPress = { disabled ? undefined : onKeyPressHandler }
role = { onClick ? role : undefined }
tabIndex = { onClick ? tabIndex : undefined }>
{customIcon ? customIcon
: icon && <Icon
className = { styles.contextMenuItemIcon }

View File

@@ -6,6 +6,7 @@ import { makeStyles } from 'tss-react/mui';
import { hideDialog } from '../../../dialog/actions';
import { IconCloseLarge } from '../../../icons/svg';
import { withPixelLineHeight } from '../../../styles/functions.web';
import { operatesWithEnterKey } from '../../functions.web';
import BaseDialog, { IProps as IBaseDialogProps } from './BaseDialog';
import Button from './Button';
@@ -108,8 +109,13 @@ const Dialog = ({
}, [ onCancel ]);
const submit = useCallback(() => {
!disableAutoHideOnSubmit && dispatch(hideDialog());
onSubmit?.();
if (onSubmit && (
(document.activeElement && !operatesWithEnterKey(document.activeElement))
|| !document.activeElement
)) {
!disableAutoHideOnSubmit && dispatch(hideDialog());
onSubmit();
}
}, [ onSubmit ]);
return (
@@ -124,11 +130,11 @@ const Dialog = ({
title = { title }
titleKey = { titleKey }>
<div className = { classes.header }>
<p
<h1
className = { classes.title }
id = 'dialog-title'>
{title ?? t(titleKey ?? '')}
</p>
</h1>
{!hideCloseButton && (
<ClickableIcon
accessibilityLabel = { t('dialog.accessibilityLabel.close') }
@@ -160,6 +166,7 @@ const Dialog = ({
accessibilityLabel = { t(ok.translationKey ?? '') }
disabled = { ok.disabled }
id = 'modal-dialog-ok-button'
isSubmit = { true }
labelKey = { ok.translationKey }
onClick = { submit } />}
</div>

View File

@@ -1,5 +1,4 @@
import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react';
import { MoveFocusInside } from 'react-focus-lock';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
@@ -317,20 +316,19 @@ const DialogWithTabs = ({
<BaseDialog
className = { cx(classes.dialog, className) }
onClose = { onClose }
size = 'large'>
size = 'large'
titleKey = { titleKey }>
{(!isMobile || !selectedTab) && (
<div
aria-orientation = 'vertical'
className = { classes.sidebar }
role = { isMobile ? undefined : 'tablist' }>
<div className = { classes.titleContainer }>
<MoveFocusInside>
<h2
className = { classes.title }
tabIndex = { -1 }>
{t(titleKey ?? '')}
</h2>
</MoveFocusInside>
<h1
className = { classes.title }
tabIndex = { -1 }>
{t(titleKey ?? '')}
</h1>
{isMobile && closeIcon}
</div>
{tabs.map((tab, index) => {
@@ -366,11 +364,11 @@ const DialogWithTabs = ({
{isMobile && (
<div className = { cx(classes.buttonContainer, classes.header) }>
<span className = { classes.backContainer }>
<h2
<h1
className = { classes.title }
tabIndex = { -1 }>
{(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)}
</h2>
</h1>
<ClickableIcon
accessibilityLabel = { t('dialog.Back') }
icon = { IconArrowBack }
@@ -401,13 +399,13 @@ const DialogWithTabs = ({
<div
className = { cx(classes.buttonContainer, classes.footer) }>
<Button
accessibilityLabel = { t('dialog.Cancel') }
accessibilityLabel = { t('dialog.accessibilityLabel.Cancel') }
id = 'modal-dialog-cancel-button'
labelKey = { 'dialog.Cancel' }
onClick = { onClose }
type = 'tertiary' />
<Button
accessibilityLabel = { t('dialog.Ok') }
accessibilityLabel = { t('dialog.accessibilityLabel.Ok') }
id = 'modal-dialog-ok-button'
labelKey = { 'dialog.Ok' }
onClick = { onSubmit } />

View File

@@ -15,7 +15,13 @@ interface IProps extends IInputProps {
bottomLabel?: string;
className?: string;
iconClick?: () => void;
id?: string;
/**
* The id to set on the input element.
* This is required because we need it internally to tie the input to its
* info (label, error) so that screen reader users don't get lost.
*/
id: string;
maxLength?: number;
maxRows?: number;
maxValue?: number;
@@ -187,7 +193,11 @@ const Input = React.forwardRef<any, IProps>(({
return (
<div className = { cx(styles.inputContainer, className) }>
{label && <span className = { cx(styles.label, isMobile && 'is-mobile') }>{label}</span>}
{label && <label
className = { cx(styles.label, isMobile && 'is-mobile') }
htmlFor = { id } >
{label}
</label>}
<div className = { styles.fieldContainer }>
{icon && <Icon
{ ...(iconClick ? { tabIndex: 0 } : {}) }
@@ -203,7 +213,7 @@ const Input = React.forwardRef<any, IProps>(({
className = { cx(styles.input, isMobile && 'is-mobile',
error && 'error', clearable && styles.clearableInput, icon && 'icon-input') }
disabled = { disabled }
{ ...(id ? { id } : {}) }
id = { id }
maxLength = { maxLength }
maxRows = { maxRows }
minRows = { minRows }
@@ -217,6 +227,7 @@ const Input = React.forwardRef<any, IProps>(({
value = { value } />
) : (
<input
aria-describedby = { bottomLabel ? `${id}-description` : undefined }
aria-label = { accessibilityLabel }
autoComplete = { autoComplete }
autoFocus = { autoFocus }
@@ -224,7 +235,7 @@ const Input = React.forwardRef<any, IProps>(({
error && 'error', clearable && styles.clearableInput, icon && 'icon-input') }
data-testid = { testId }
disabled = { disabled }
{ ...(id ? { id } : {}) }
id = { id }
{ ...(mode ? { inputmode: mode } : {}) }
{ ...(type === 'number' ? { max: maxValue } : {}) }
maxLength = { maxLength }
@@ -249,7 +260,9 @@ const Input = React.forwardRef<any, IProps>(({
</button>}
</div>
{bottomLabel && (
<span className = { cx(styles.bottomLabel, isMobile && 'is-mobile', error && 'error') }>
<span
className = { cx(styles.bottomLabel, isMobile && 'is-mobile', error && 'error') }
id = { `${id}-description` }>
{bottomLabel}
</span>
)}

View File

@@ -14,6 +14,7 @@ interface IProps {
error?: boolean;
errorDialog?: JSX.Element | null;
filterValue?: string;
id: string;
isOpen?: boolean;
items: MultiSelectItem[];
noMatchesText?: string;
@@ -101,6 +102,7 @@ const MultiSelect = ({
error,
errorDialog,
placeholder,
id,
items,
filterValue,
onFilterChange,
@@ -145,6 +147,7 @@ const MultiSelect = ({
<Input
autoFocus = { autoFocus }
disabled = { disabled }
id = { id }
onChange = { onFilterChange }
placeholder = { placeholder }
ref = { inputRef }

View File

@@ -28,6 +28,12 @@ interface ISelectProps {
*/
error?: boolean;
/**
* Id of the <select> element.
* Necessary for screen reader users, to link the label and error to the select.
*/
id: string;
/**
* Label to be displayed above the select.
*/
@@ -140,6 +146,7 @@ const Select = ({
className,
disabled,
error,
id,
label,
onChange,
options,
@@ -149,11 +156,17 @@ const Select = ({
return (
<div className = { classes.container }>
{label && <span className = { cx(classes.label, isMobile && 'is-mobile') }>{label}</span>}
{label && <label
className = { cx(classes.label, isMobile && 'is-mobile') }
htmlFor = { id } >
{label}
</label>}
<div className = { classes.selectContainer }>
<select
aria-describedby = { bottomLabel ? `${id}-description` : undefined }
className = { cx(classes.select, isMobile && 'is-mobile', className, error && 'error') }
disabled = { disabled }
id = { id }
onChange = { onChange }
value = { value }>
{options.map(option => (<option
@@ -167,7 +180,9 @@ const Select = ({
src = { IconArrowDown } />
</div>
{bottomLabel && (
<span className = { cx(classes.bottomLabel, isMobile && 'is-mobile', error && 'error') }>
<span
className = { cx(classes.bottomLabel, isMobile && 'is-mobile', error && 'error') }
id = { `${id}-description` }>
{bottomLabel}
</span>
)}

View File

@@ -52,6 +52,7 @@ const useStyles = makeStyles()(theme => {
width: '16px',
height: '16px',
position: 'absolute',
zIndex: 5,
top: '4px',
left: '4px',
backgroundColor: theme.palette.ui10,
@@ -73,8 +74,38 @@ const useStyles = makeStyles()(theme => {
},
checkbox: {
height: 0,
width: 0
position: 'absolute',
zIndex: 10,
cursor: 'pointer',
left: 0,
right: 0,
top: 0,
bottom: 0,
width: '100%',
height: '100%',
opacity: 0,
'&.focus-visible + .toggle-checkbox-ring': {
outline: 0,
boxShadow: `0px 0px 0px 2px ${theme.palette.focus01}`
}
},
checkboxRing: {
position: 'absolute',
pointerEvents: 'none',
zIndex: 6,
left: 0,
right: 0,
top: 0,
bottom: 0,
width: '100%',
height: '100%',
borderRadius: '12px',
'&.is-mobile': {
borderRadius: '32px'
}
}
};
});
@@ -88,7 +119,7 @@ const Switch = ({ className, id, checked, disabled, onChange }: IProps) => {
}, []);
return (
<label
<span
className = { cx('toggle-container', styles.container, checked && styles.containerOn,
isMobile && 'is-mobile', disabled && 'disabled', className) }>
<input
@@ -98,8 +129,9 @@ const Switch = ({ className, id, checked, disabled, onChange }: IProps) => {
className = { styles.checkbox }
disabled = { disabled }
onChange = { change } />
<div className = { cx('toggle-checkbox-ring', styles.checkboxRing, isMobile && 'is-mobile') } />
<div className = { cx('toggle', styles.toggle, checked && styles.toggleOn, isMobile && 'is-mobile') } />
</label>
</span>
);
};