Make (most) UI elements reachable via keyboard (#12657)

feat(a11y): make (most) UI elements reachable via keyboard
This commit is contained in:
Emmanuel Pelletier 2023-02-28 11:21:15 +01:00 committed by GitHub
parent 778bca3031
commit c81777a475
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 247 additions and 32 deletions

View File

@ -773,6 +773,7 @@
},
"passwordDigitsOnly": "Up to {{number}} digits",
"passwordSetRemotely": "Set by another participant",
"pinParticipant": "{{participantName}} - Pin",
"pinnedParticipant": "The participant is pinned",
"polls": {
"answer": {
@ -1249,6 +1250,7 @@
"subtitlesOff": "Off",
"tr": "TR"
},
"unpinParticipant": "{{participantName}} - Unpin",
"userMedia": {
"androidGrantPermissions": "Select <b><i>Allow</i></b> when your browser asks for permissions.",
"chromeGrantPermissions": "Select <b><i>Allow</i></b> when your browser asks for permissions.",

View File

@ -88,7 +88,7 @@ const useStyles = makeStyles()(theme => {
boxShadow: 'inset 0px -1px 0px rgba(255, 255, 255, 0.15)',
minHeight: '40px',
'&:hover': {
'&:hover, &:focus-within': {
backgroundColor: theme.palette.ui02,
'& .indicators': {
@ -97,6 +97,8 @@ const useStyles = makeStyles()(theme => {
'& .actions': {
display: 'flex',
position: 'relative',
top: 'auto',
boxShadow: `-15px 0px 10px -5px ${theme.palette.ui02}`,
backgroundColor: theme.palette.ui02
}
@ -154,7 +156,8 @@ const useStyles = makeStyles()(theme => {
},
actionsContainer: {
display: 'none',
position: 'absolute',
top: '-1000px',
boxShadow: `-15px 0px 10px -5px ${theme.palette.ui02}`,
backgroundColor: theme.palette.ui02
},

View File

@ -1,4 +1,4 @@
import React from 'react';
import React, { useCallback } from 'react';
import { makeStyles } from 'tss-react/mui';
import Icon from '../../../icons/components/Icon';
@ -92,13 +92,27 @@ const Label = ({
}: IProps) => {
const { classes, cx } = useStyles();
const onKeyPress = useCallback(event => {
if (!onClick) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onClick();
}
}, [ onClick ]);
return (
<div
className = { cx(classes.label, onClick && classes.clickable,
color && classes[color], className
) }
id = { id }
onClick = { onClick }>
onClick = { onClick }
onKeyPress = { onKeyPress }
role = { onClick ? 'button' : undefined }
tabIndex = { onClick ? 0 : undefined }>
{icon && <Icon
color = { iconColor }
size = '16'

View File

@ -1,4 +1,5 @@
import React, { Component, ReactNode } from 'react';
import ReactFocusLock from 'react-focus-lock';
import { IReduxState } from '../../../app/types';
import DialogPortal from '../../../toolbox/components/web/DialogPortal';
@ -34,6 +35,18 @@ interface IProps {
*/
disablePopover?: boolean;
/**
* The id of the dom element acting as the Popover label (matches aria-labelledby).
*/
headingId?: string;
/**
* String acting as the Popover label (matches aria-label).
*
* If headingId is set, this will not be used.
*/
headingLabel?: string;
/**
* An id attribute to apply to the root of the {@code Popover}
* component.
@ -186,7 +199,16 @@ class Popover extends Component<IProps, IState> {
* @returns {ReactElement}
*/
render() {
const { children, className, content, id, overflowDrawer, visible, trigger } = this.props;
const { children,
className,
content,
headingId,
headingLabel,
id,
overflowDrawer,
visible,
trigger
} = this.props;
if (overflowDrawer) {
return (
@ -197,6 +219,7 @@ class Popover extends Component<IProps, IState> {
{ children }
<JitsiPortal>
<Drawer
headingId = { headingId }
isOpen = { visible }
onClose = { this._onHideDialog }>
{ content }
@ -214,7 +237,8 @@ class Popover extends Component<IProps, IState> {
onKeyPress = { this._onKeyPress }
{ ...(trigger === 'hover' ? {
onMouseEnter: this._onShowDialog,
onMouseLeave: this._onHideDialog
onMouseLeave: this._onHideDialog,
tabIndex: 0
} : {}) }
ref = { this._containerRef }>
{ visible && (
@ -222,7 +246,16 @@ class Popover extends Component<IProps, IState> {
getRef = { this._setContextMenuRef }
setSize = { this._setContextMenuStyle }
style = { this.state.contextMenuStyle }>
{this._renderContent()}
<ReactFocusLock
lockProps = {{
role: 'dialog',
'aria-modal': true,
'aria-labelledby': headingId,
'aria-label': !headingId && headingLabel ? headingLabel : undefined
}}
returnFocus = { true }>
{this._renderContent()}
</ReactFocusLock>
</DialogPortal>
)}
{ children }

View File

@ -115,7 +115,6 @@ class MeetingsList extends Component<Props> {
<Container
aria-label = { t('welcomepage.recentList') }
className = 'meetings-list'
role = 'menu'
tabIndex = '-1'>
{
meetings.length === 0
@ -243,7 +242,7 @@ class MeetingsList extends Component<Props> {
key = { index }
onClick = { onPress }
onKeyPress = { onKeyPress }
role = 'menuitem'
role = 'button'
tabIndex = { 0 }>
<Container className = 'left-column'>
<Text className = 'title'>

View File

@ -84,7 +84,7 @@ export default class ToolboxItem extends AbstractToolboxItem<Props> {
onKeyDown: disabled ? undefined : onKeyDown,
onKeyPress: this._onKeyPress,
tabIndex: 0,
role: showLabel ? 'menuitem' : 'button'
role: 'button'
};
const elementType = showLabel ? 'li' : 'div';

View File

@ -123,7 +123,7 @@ class OverflowMenuItem extends Component<Props> {
className = { className }
onClick = { disabled ? null : onClick }
onKeyPress = { this._onKeyPress }
role = 'menuitem'
role = 'button'
tabIndex = { 0 }>
<span className = 'overflow-menu-item-icon'>
<Icon

View File

@ -121,6 +121,7 @@ export default function ToolboxButtonWithIconPopup(props: Props) {
<div className = 'settings-button-small-icon-container'>
<Popover
content = { popoverContent }
headingLabel = { ariaLabel }
onPopoverClose = { onPopoverClose }
onPopoverOpen = { onPopoverOpen }
position = 'top'

View File

@ -65,7 +65,7 @@ export default class ToolboxItem extends AbstractToolboxItem<Props> {
onClick: disabled ? undefined : onClick,
onKeyPress: this._onKeyPress,
tabIndex: 0,
role: showLabel ? 'menuitem' : 'button'
role: 'button'
};
const elementType = showLabel ? 'li' : 'div';

View File

@ -182,7 +182,9 @@ const BaseDialog = ({
<div
className = { classes.backdrop }
onClick = { onBackdropClick } />
<FocusLock className = { classes.focusLock }>
<FocusLock
className = { classes.focusLock }
returnFocus = { true }>
<div
aria-describedby = { description }
aria-labelledby = { title ?? t(titleKey ?? '') }

View File

@ -262,7 +262,7 @@ const ContextMenu = ({
onMouseEnter = { onMouseEnter }
onMouseLeave = { onMouseLeave }
ref = { containerRef }
role = { role ?? 'menu' }
role = { role }
tabIndex = { tabIndex }>
{children}
</div>;

View File

@ -172,7 +172,8 @@ const ContextMenuItem = ({
onClick = { disabled ? undefined : onClick }
onKeyDown = { disabled ? undefined : onKeyDown }
onKeyPress = { disabled ? undefined : onKeyPress }
role = 'menuitem'>
role = 'button'
tabIndex = { disabled ? undefined : 0 }>
{customIcon ? customIcon
: icon && <Icon
className = { styles.contextMenuItemIcon }

View File

@ -9,6 +9,7 @@ import { connect } from '../../../base/redux';
import E2EELabel from '../../../e2ee/components/E2EELabel';
import HighlightButton from '../../../recording/components/Recording/web/HighlightButton';
import RecordingLabel from '../../../recording/components/web/RecordingLabel';
import { showToolbox } from '../../../toolbox/actions';
import { isToolboxVisible } from '../../../toolbox/functions.web';
import TranscribingLabel from '../../../transcribing/components/TranscribingLabel.web';
import VideoQualityLabel from '../../../video-quality/components/VideoQualityLabel.web';
@ -33,6 +34,11 @@ type Props = {
*/
_conferenceInfo: Object,
/**
* Invoked to active other features of the app.
*/
dispatch: Function;
/**
* Indicates whether the component should be visible or not.
*/
@ -113,6 +119,21 @@ class ConferenceInfo extends Component<Props> {
this._renderAutoHide = this._renderAutoHide.bind(this);
this._renderAlwaysVisible = this._renderAlwaysVisible.bind(this);
this._onTabIn = this._onTabIn.bind(this);
}
_onTabIn: () => void;
/**
* Callback invoked when the component is focused to show the conference
* info if necessary.
*
* @returns {void}
*/
_onTabIn() {
if (this.props._conferenceInfo.autoHide?.length && !this.props._visible) {
this.props.dispatch(showToolbox());
}
}
_renderAutoHide: () => void;
@ -181,7 +202,9 @@ class ConferenceInfo extends Component<Props> {
*/
render() {
return (
<div className = 'details-container' >
<div
className = 'details-container'
onFocus = { this._onTabIn }>
{ this._renderAlwaysVisible() }
{ this._renderAutoHide() }
</div>

View File

@ -216,7 +216,7 @@ class ConnectionIndicator extends AbstractConnectionIndicator<Props, IState> {
*/
render() {
// @ts-ignore
const { enableStatsDisplay, participantId, statsPopoverPosition, classes } = this.props;
const { enableStatsDisplay, participantId, statsPopoverPosition, classes, t } = this.props;
const visibilityClass = this._getVisibilityClass();
// @ts-ignore
@ -233,6 +233,7 @@ class ConnectionIndicator extends AbstractConnectionIndicator<Props, IState> {
inheritedStats = { this.state.stats }
participantId = { participantId } /> }
disablePopover = { !enableStatsDisplay }
headingLabel = { t('videothumbnail.connectionInfo') }
id = 'participant-connection-indicator'
onPopoverClose = { this._onHidePopover }
onPopoverOpen = { this._onShowPopover }

View File

@ -3,7 +3,8 @@ import { Theme } from '@mui/material';
import { withStyles } from '@mui/styles';
import clsx from 'clsx';
import debounce from 'lodash/debounce';
import React, { Component } from 'react';
import React, { Component, KeyboardEvent, RefObject, createRef } from 'react';
import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
import { createScreenSharingIssueEvent } from '../../../analytics/AnalyticsEvents';
@ -12,6 +13,7 @@ import { IReduxState } from '../../../app/types';
// @ts-ignore
import { Avatar } from '../../../base/avatar';
import { isMobileBrowser } from '../../../base/environment/utils';
import { translate } from '../../../base/i18n/functions';
import { JitsiTrackEvents } from '../../../base/lib-jitsi-meet';
// @ts-ignore
import { VideoTrack } from '../../../base/media';
@ -28,6 +30,8 @@ import {
import { IParticipant } from '../../../base/participants/types';
import { ASPECT_RATIO_NARROW } from '../../../base/responsive-ui/constants';
import { isTestModeEnabled } from '../../../base/testing/functions';
// @ts-ignore
import { Tooltip } from '../../../base/tooltip';
import { trackStreamingStatusChanged, updateLastTrackVideoMediaEvent } from '../../../base/tracks/actions';
import {
getLocalAudioTrack,
@ -97,7 +101,7 @@ export interface IState {
/**
* The type of the React {@code Component} props of {@link Thumbnail}.
*/
export interface IProps {
export interface IProps extends WithTranslation {
/**
* The audio track related to the participant.
@ -372,6 +376,22 @@ const defaultStyles = (theme: Theme) => {
height: '100%',
backgroundColor: `${theme.palette.uiBackground}`,
opacity: 0.8
},
keyboardPinButton: {
position: 'absolute' as const,
zIndex: 10,
/* this button is only for keyboard/screen reader users,
an onClick handler is already set elsewhere for mouse users, so make sure
we can't click on it */
pointerEvents: 'none' as const,
// make room for the border to correctly show up
left: '3px',
right: '3px',
bottom: '3px',
top: '3px'
}
};
};
@ -387,6 +407,11 @@ class Thumbnail extends Component<IProps, IState> {
*/
timeoutHandle?: number;
/**
* Ref to the container of the thumbnail.
*/
containerRef?: RefObject<HTMLSpanElement>;
/**
* Timeout used to detect double tapping.
* It is active while user has tapped once.
@ -414,10 +439,13 @@ class Thumbnail extends Component<IProps, IState> {
displayMode: computeDisplayModeFromInput(getDisplayModeInput(props, state))
};
this.timeoutHandle = undefined;
this.containerRef = createRef<HTMLSpanElement>();
this._clearDoubleClickTimeout = this._clearDoubleClickTimeout.bind(this);
this._onCanPlay = this._onCanPlay.bind(this);
this._onClick = this._onClick.bind(this);
this._onTogglePinButtonKeyDown = this._onTogglePinButtonKeyDown.bind(this);
this._onFocus = this._onFocus.bind(this);
this._onBlur = this._onBlur.bind(this);
this._onMouseEnter = this._onMouseEnter.bind(this);
this._onMouseMove = debounce(this._onMouseMove.bind(this), 100, {
leading: true,
@ -731,6 +759,53 @@ class Thumbnail extends Component<IProps, IState> {
}
}
/**
* This is called as a onKeydown handler on the keyboard-only button to toggle pin.
*
* @param {KeyboardEvent} event - The keydown event.
* @returns {void}
*/
_onTogglePinButtonKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' || event.key === ' ') {
this._onClick();
}
}
/**
* Keyboard focus handler.
*
* When navigating with keyboard, make things behave as we
* hover with the mouse, to make the UI show up.
*
* @returns {void}
*/
_onFocus() {
this.setState({ isHovered: true });
}
/**
* Keyboard blur handler.
*
* When navigating with keyboard, make things behave as we
* hover with the mouse, to make the UI show up.
*
* @returns {void}
*/
_onBlur() {
// we need this timeout trick so that we get the actual document.activeElement value
// instead of document.body
setTimeout(() => {
// we also explicitly check for popovers, because the thumbnail can show popovers,
// and they are not rendered in the thumbnail DOM element
if (
!this.containerRef?.current?.contains(document.activeElement)
&& document.activeElement?.closest('.popover') === null
) {
this.setState({ isHovered: false });
}
}, 0);
}
/**
* Mouse enter handler.
*
@ -808,21 +883,27 @@ class Thumbnail extends Component<IProps, IState> {
* @returns {ReactElement}
*/
_renderFakeParticipant() {
const { _isMobile, _participant: { avatarURL } } = this.props;
const { _isMobile, _participant: { avatarURL, pinned, name } } = this.props;
const styles = this._getStyles();
const containerClassName = this._getContainerClassName();
return (
<span
aria-label = { this.props.t(pinned ? 'unpinParticipant' : 'pinParticipant', {
participantName: name
}) }
className = { containerClassName }
id = 'sharedVideoContainer'
onClick = { this._onClick }
onKeyDown = { this._onTogglePinButtonKeyDown }
{ ...(_isMobile ? {} : {
onMouseEnter: this._onMouseEnter,
onMouseMove: this._onMouseMove,
onMouseLeave: this._onMouseLeave
}) }
style = { styles.thumbnail }>
role = 'button'
style = { styles.thumbnail }
tabIndex = { 0 }>
{avatarURL ? (
<img
className = 'sharedVideoAvatar'
@ -981,9 +1062,10 @@ class Thumbnail extends Component<IProps, IState> {
_thumbnailType,
_videoTrack,
classes,
filmstripType
filmstripType,
t
} = this.props;
const { id } = _participant || {};
const { id, name, pinned } = _participant || {};
const { isHovered, popoverVisible } = this.state;
const styles = this._getStyles();
let containerClassName = this._getContainerClassName();
@ -992,6 +1074,9 @@ class Thumbnail extends Component<IProps, IState> {
const jitsiVideoTrack = _videoTrack?.jitsiTrack;
const videoTrackId = jitsiVideoTrack?.getId();
const videoEventListeners: any = {};
const pinButtonLabel = t(pinned ? 'unpinParticipant' : 'pinParticipant', {
participantName: name
});
if (local) {
if (_isMobilePortrait) {
@ -1022,6 +1107,8 @@ class Thumbnail extends Component<IProps, IState> {
? `localVideoContainer${filmstripType === FILMSTRIP_TYPE.MAIN ? '' : `_${filmstripType}`}`
: `participant_${id}${filmstripType === FILMSTRIP_TYPE.MAIN ? '' : `_${filmstripType}`}`
}
onBlur = { this._onBlur }
onFocus = { this._onFocus }
{ ...(_isMobile
? {
onTouchEnd: this._onTouchEnd,
@ -1035,7 +1122,19 @@ class Thumbnail extends Component<IProps, IState> {
onMouseLeave: this._onMouseLeave
}
) }
ref = { this.containerRef }
style = { styles.thumbnail }>
{/* this "button" is invisible, only here so that
keyboard/screen reader users can pin/unpin */}
<Tooltip
content = { pinButtonLabel }>
<span
aria-label = { pinButtonLabel }
className = { classes.keyboardPinButton }
onKeyDown = { this._onTogglePinButtonKeyDown }
role = 'button'
tabIndex = { 0 } />
</Tooltip>
{!_gifSrc && (local
? <span id = 'localVideoWrapper'>{video}</span>
: video)}
@ -1322,4 +1421,4 @@ function _mapStateToProps(state: IReduxState, ownProps: any): Object {
};
}
export default connect(_mapStateToProps)(withStyles(defaultStyles)(Thumbnail));
export default connect(_mapStateToProps)(withStyles(defaultStyles)(translate(Thumbnail)));

View File

@ -26,7 +26,7 @@ export const styles = (theme: Theme) => {
transition: 'opacity .3s',
zIndex: 1,
'&:hover': {
'&:hover, &:focus-within': {
backgroundColor: theme.palette.ui02
}
},
@ -70,7 +70,7 @@ export const styles = (theme: Theme) => {
right: 0,
bottom: 0,
'&:hover': {
'&:hover, &:focus-within': {
'& .resizable-filmstrip': {
backgroundColor: BACKGROUND_COLOR
},
@ -106,7 +106,7 @@ export const styles = (theme: Theme) => {
filmstripBackground: {
backgroundColor: theme.palette.uiBackground,
'&:hover': {
'&:hover, &:focus-within': {
backgroundColor: theme.palette.uiBackground
}
},

View File

@ -75,6 +75,7 @@ function AudioSettingsPopup({
outputDevices = { outputDevices }
setAudioInputDevice = { setAudioInputDevice }
setAudioOutputDevice = { setAudioOutputDevice } /> }
headingId = 'audio-settings-button'
onPopoverClose = { onClose }
position = { popupPlacement }
trigger = 'click'

View File

@ -62,6 +62,7 @@ function VideoSettingsPopup({
setVideoInputDevice = { setVideoInputDevice }
toggleVideoSettings = { onClose }
videoDeviceIds = { videoDeviceIds } /> }
headingId = 'video-settings-button'
onPopoverClose = { onClose }
position = { popupPlacement }
trigger = 'click'

View File

@ -1,4 +1,5 @@
import React, { ReactNode, useCallback } from 'react';
import React, { KeyboardEvent, ReactNode, useCallback } from 'react';
import ReactFocusLock from 'react-focus-lock';
import { makeStyles } from 'tss-react/mui';
import { DRAWER_MAX_HEIGHT } from '../../constants';
@ -16,6 +17,11 @@ interface IProps {
*/
className?: string;
/**
* The id of the dom element acting as the Drawer label.
*/
headingId?: string;
/**
* Whether the drawer should be shown or not.
*/
@ -45,6 +51,7 @@ const useStyles = makeStyles()(theme => {
function Drawer({
children,
className = '',
headingId,
isOpen,
onClose
}: IProps) {
@ -71,15 +78,38 @@ function Drawer({
onClose?.();
}, [ onClose ]);
/**
* Handles pressing the escape key, closing the drawer.
*
* @param {KeyboardEvent<HTMLDivElement>} event - The keydown event.
* @returns {void}
*/
const handleEscKey = useCallback((event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
onClose?.();
}
}, [ onClose ]);
return (
isOpen ? (
<div
className = 'drawer-menu-container'
onClick = { handleOutsideClick }>
onClick = { handleOutsideClick }
onKeyDown = { handleEscKey }>
<div
className = { `drawer-menu ${styles.drawer} ${className}` }
onClick = { handleInsideClick }>
{children}
<ReactFocusLock
lockProps = {{
role: 'dialog',
'aria-modal': true,
'aria-labelledby': `#${headingId}`
}}
returnFocus = { true }>
{children}
</ReactFocusLock>
</div>
</div>
) : null

View File

@ -83,12 +83,13 @@ class HangupMenuButton extends Component<IProps> {
* @returns {ReactElement}
*/
render() {
const { children, isOpen } = this.props;
const { children, isOpen, t } = this.props;
return (
<div className = 'toolbox-button-wth-dialog context-menu'>
<Popover
content = { children }
headingLabel = { t('toolbar.accessibilityLabel.hangup') }
onPopoverClose = { this._onCloseDialog }
position = 'top'
trigger = 'click'

View File

@ -123,6 +123,7 @@ const OverflowMenuButton = ({
) : (
<Popover
content = { children }
headingId = 'overflow-context-menu'
onPopoverClose = { onCloseDialog }
onPopoverOpen = { onOpenDialog }
position = 'top'

View File

@ -1466,6 +1466,7 @@ class Toolbox extends Component<IProps> {
accessibilityLabel = { t(toolbarAccLabel) }
className = { classes.contextMenu }
hidden = { false }
id = 'overflow-context-menu'
inDrawer = { _overflowDrawer }
onKeyDown = { this._onEscKey }>
{overflowMenuButtons.reduce((acc, val) => {

View File

@ -216,6 +216,7 @@ class LocalVideoMenuTriggerButton extends Component<IProps> {
isMobileBrowser() || _showLocalVideoFlipButton || _showHideSelfViewButton
? <Popover
content = { content }
headingLabel = { t('dialog.localUserControls') }
id = 'local-video-menu-trigger'
onPopoverClose = { this._onPopoverClose }
onPopoverOpen = { this._onPopoverOpen }

View File

@ -190,6 +190,7 @@ class RemoteVideoMenuTriggerButton extends Component<IProps> {
return (
<Popover
content = { content }
headingLabel = { this.props.t('dialog.remoteUserControls', { username }) }
id = 'remote-video-menu-trigger'
onPopoverClose = { this._onPopoverClose }
onPopoverOpen = { this._onPopoverOpen }