From 9c3e565f416d656b21ec752019d4f144bd1e51b2 Mon Sep 17 00:00:00 2001 From: Emmanuel Pelletier Date: Thu, 2 Mar 2023 19:26:46 +0100 Subject: [PATCH] fix(a11y/dialogsWithTabs) better keyboard/sr support via tabs aria pattern: - use Tabs ARIA pattern when not on mobile, as we should - make sure keyboard focus is handled correctly also on mobile when moving back and forth the menu items/the content - move some DOM elements so that tab order is consistent - better "close dialog" a11y label, "close" is a bit vague, especially if the dialog itself has lots of other interactive components --- lang/main.json | 1 + .../ui/components/web/ContextMenuItem.tsx | 25 +++- .../base/ui/components/web/Dialog.tsx | 2 +- .../base/ui/components/web/DialogWithTabs.tsx | 128 ++++++++++++++---- 4 files changed, 129 insertions(+), 27 deletions(-) diff --git a/lang/main.json b/lang/main.json index 458f066d2..473aef9a4 100644 --- a/lang/main.json +++ b/lang/main.json @@ -240,6 +240,7 @@ "WaitingForHostTitle": "Waiting for the host ...", "Yes": "Yes", "accessibilityLabel": { + "close": "Close dialog", "liveStreaming": "Live Stream" }, "add": "Add", diff --git a/react/features/base/ui/components/web/ContextMenuItem.tsx b/react/features/base/ui/components/web/ContextMenuItem.tsx index 3731dc38d..6dfe873b2 100644 --- a/react/features/base/ui/components/web/ContextMenuItem.tsx +++ b/react/features/base/ui/components/web/ContextMenuItem.tsx @@ -26,6 +26,13 @@ export interface IProps { */ className?: string; + /** + * Id of dom element controlled by this item. Matches aria-controls. + * Useful if you need this item as a tab element. + * + */ + controls?: string; + /** * Custom icon. If used, the icon prop is ignored. * Used to allow custom children instead of just the default icons. @@ -55,7 +62,7 @@ export interface IProps { /** * Keydown handler. */ - onKeyDown?: (e?: React.KeyboardEvent) => void; + onKeyDown?: (e: React.KeyboardEvent) => void; /** * Keypress handler. @@ -67,6 +74,11 @@ export interface IProps { */ overflowType?: TEXT_OVERFLOW_TYPES; + /** + * You can use this item as a tab. Defaults to button if not set. + */ + role?: 'tab' | 'button'; + /** * Whether the item is marked as selected. */ @@ -150,6 +162,7 @@ const ContextMenuItem = ({ accessibilityLabel, children, className, + controls, customIcon, disabled, id, @@ -158,6 +171,7 @@ const ContextMenuItem = ({ onKeyDown, onKeyPress, overflowType, + role = 'button', selected, testId, text, @@ -167,8 +181,10 @@ const ContextMenuItem = ({ return (
+ role = { role } + tabIndex = { role === 'tab' + ? selected ? 0 : -1 + : disabled ? undefined : 0 + }> {customIcon ? customIcon : icon && {!hideCloseButton && ( diff --git a/react/features/base/ui/components/web/DialogWithTabs.tsx b/react/features/base/ui/components/web/DialogWithTabs.tsx index 3f3e12be0..1f5042585 100644 --- a/react/features/base/ui/components/web/DialogWithTabs.tsx +++ b/react/features/base/ui/components/web/DialogWithTabs.tsx @@ -1,4 +1,5 @@ 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'; @@ -89,10 +90,6 @@ const useStyles = makeStyles()(theme => { } }, - closeButtonContainer: { - paddingBottom: theme.spacing(4) - }, - buttonContainer: { width: '100%', boxSizing: 'border-box', @@ -109,6 +106,7 @@ const useStyles = makeStyles()(theme => { backContainer: { display: 'flex', + flexDirection: 'row-reverse', alignItems: 'center', '& > button': { @@ -127,6 +125,11 @@ const useStyles = makeStyles()(theme => { } }, + header: { + order: -1, + paddingBottom: theme.spacing(4) + }, + footer: { justifyContent: 'flex-end', paddingTop: theme.spacing(4), @@ -168,6 +171,7 @@ const DialogWithTabs = ({ const dispatch = useDispatch(); const { t } = useTranslation(); const [ selectedTab, setSelectedTab ] = useState(defaultTab ?? tabs[0].name); + const [ userSelected, setUserSelected ] = useState(false); const [ tabStates, setTabStates ] = useState(tabs.map(tab => tab.props)); const clientWidth = useSelector((state: IReduxState) => state['features/base/responsive-ui'].clientWidth); const [ isMobile, setIsMobile ] = useState(false); @@ -188,18 +192,58 @@ const DialogWithTabs = ({ } }, [ isMobile ]); - const back = useCallback(() => { - setSelectedTab(undefined); + const onUserSelection = useCallback((tabName?: string) => { + setUserSelected(true); + setSelectedTab(tabName); }, []); + const back = useCallback(() => { + onUserSelection(undefined); + }, []); + + + // the userSelected state is used to prevent setting focus when the user + // didn't actually interact (for the first rendering for example) + useEffect(() => { + if (userSelected) { + document.querySelector(isMobile + ? `.${classes.title}` + : `#${`dialogtab-button-${selectedTab}`}` + )?.focus(); + setUserSelected(false); + } + }, [ isMobile, userSelected, selectedTab ]); + const onClose = useCallback(() => { dispatch(hideDialog()); }, []); const onClick = useCallback((tabName: string) => () => { - setSelectedTab(tabName); + onUserSelection(tabName); }, []); + const onTabKeyDown = useCallback((index: number) => (event: React.KeyboardEvent) => { + let newTab: IDialogTab | null = null; + + if (event.key === 'ArrowUp') { + newTab = index === 0 ? tabs[tabs.length - 1] : tabs[index - 1]; + } + + if (event.key === 'ArrowDown') { + newTab = index === tabs.length - 1 ? tabs[0] : tabs[index + 1]; + } + + if (newTab !== null) { + onUserSelection(newTab.name); + } + }, [ tabs.length ]); + + const onMobileKeyDown = useCallback((tabName: string) => (event: React.KeyboardEvent) => { + if (event.key === ' ' || event.key === 'Enter') { + onUserSelection(tabName); + } + }, [ classes.contentContainer ]); + const getTabProps = (tabId: number) => { const tabConfiguration = tabs[tabId]; const currentTabState = tabStates[tabId]; @@ -256,7 +300,7 @@ const DialogWithTabs = ({ const closeIcon = useMemo(() => ( @@ -268,21 +312,39 @@ const DialogWithTabs = ({ onClose = { onClose } size = 'large'> {(!isMobile || !selectedTab) && ( -
+
-

{t(titleKey ?? '')}

+ +

+ {t(titleKey ?? '')} +

+
{isMobile && closeIcon}
- {tabs.map(tab => { + {tabs.map((tab, index) => { const label = t(tab.labelKey); + /** + * When not on mobile, the items behave as tabs, + * that's why we set `controls`, `role` and `selected` attributes + * only when not on mobile, they are useful only for the tab behavior. + */ return ( ); @@ -290,25 +352,45 @@ const DialogWithTabs = ({
)} {(!isMobile || selectedTab) && ( -
-
- {isMobile && ( +
+ {/* DOM order is important for keyboard users: show whole heading first when on mobile… */} + {isMobile && ( +
+

+ {(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)} +

-

- {(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)} -

- )} - {closeIcon} -
-
- {selectedTabComponent} -
+ {closeIcon} +
+ )} + {tabs.map(tab => ( +
+ { tab.name === selectedTab && selectedTabComponent } +
+ ))} + {/* But show the close button *after* tab panels when not on mobile (using tabs). + This is so that we can tab back and forth tab buttons and tab panels easily. */} + {!isMobile && ( +
+ {closeIcon} +
+ )}