From f727b9295fc1eed81a8a1cda2feadf024183c3b8 Mon Sep 17 00:00:00 2001 From: Emmanuel Pelletier Date: Mon, 6 Mar 2023 16:13:29 +0100 Subject: [PATCH] Use tabs ARIA design pattern when using tabbed UI (#12994) feat(a11y): use tabs ARIA design pattern when using tabbed UI --- css/_meetings_list.scss | 15 +- css/_polls.scss | 2 +- css/_welcome_page.scss | 6 +- lang/main.json | 5 +- .../base/react/components/web/MeetingsList.js | 36 +++-- .../ui/components/web/ContextMenuItem.tsx | 25 +++- .../base/ui/components/web/Dialog.tsx | 2 +- .../base/ui/components/web/DialogWithTabs.tsx | 128 ++++++++++++++--- .../features/base/ui/components/web/Tabs.tsx | 41 +++++- .../components/CalendarList.web.js | 3 +- react/features/chat/components/web/Chat.js | 41 +++--- .../chat/components/web/ChatInput.tsx | 1 - .../components/DesktopPicker.tsx | 38 +++-- .../polls/components/web/PollsPane.tsx | 1 - react/features/welcome/components/Tab.js | 76 ---------- react/features/welcome/components/Tabs.js | 133 +++++++++++------- .../welcome/components/WelcomePage.web.js | 24 +--- 17 files changed, 338 insertions(+), 239 deletions(-) delete mode 100644 react/features/welcome/components/Tab.js diff --git a/css/_meetings_list.scss b/css/_meetings_list.scss index ecbc76f7f..563e04bbf 100644 --- a/css/_meetings_list.scss +++ b/css/_meetings_list.scss @@ -82,6 +82,7 @@ } .left-column { + order: -1; display: flex; flex-direction: column; flex-grow: 0; @@ -92,6 +93,7 @@ .right-column { display: flex; flex-direction: column; + align-items: flex-start; flex-grow: 1; padding-left: 16px; padding-top: 13px; @@ -99,11 +101,11 @@ } .title { - font-size: 12px; - font-weight: 600; - line-height: 16px; - padding-bottom: 4px; - } + font-size: 12px; + font-weight: 600; + line-height: 16px; + margin-bottom: 4px; + } .subtitle { color: #5E6D7A; @@ -125,8 +127,7 @@ cursor: pointer; } - &.with-click-handler:hover, - &.with-click-handler:focus { + &.with-click-handler:hover { background-color: #c7ddff; } diff --git a/css/_polls.scss b/css/_polls.scss index 1ee433269..966f7452e 100644 --- a/css/_polls.scss +++ b/css/_polls.scss @@ -1,3 +1,3 @@ -#polls-panel { +.polls-panel { height: calc(100% - 119px); } diff --git a/css/_welcome_page.scss b/css/_welcome_page.scss index ebdae67f8..59344150d 100644 --- a/css/_welcome_page.scss +++ b/css/_welcome_page.scss @@ -167,7 +167,7 @@ body.welcome-page { margin: 4px; display: $welcomePageTabButtonsDisplay; - .tab { + [role="tab"] { background-color: #c7ddff; border-radius: 7px; cursor: pointer; @@ -176,8 +176,10 @@ body.welcome-page { margin: 2px; padding: 7px 0; text-align: center; + color: inherit; + border: 0; - &.selected { + &[aria-selected="true"] { background-color: #FFF; } } diff --git a/lang/main.json b/lang/main.json index f9e3e520d..cb721a35b 100644 --- a/lang/main.json +++ b/lang/main.json @@ -240,7 +240,9 @@ "WaitingForHostTitle": "Waiting for the host ...", "Yes": "Yes", "accessibilityLabel": { - "liveStreaming": "Live Stream" + "close": "Close dialog", + "liveStreaming": "Live Stream", + "sharingTabs": "Sharing options" }, "add": "Add", "addMeetingNote": "Add a note about this meeting", @@ -1387,6 +1389,7 @@ "microsoftLogo": "Microsoft logo", "policyLogo": "Policy logo" }, + "meetingsAccessibilityLabel": "Meetings", "mobileDownLoadLinkAndroid": "Download mobile app for Android", "mobileDownLoadLinkFDroid": "Download mobile app for F-Droid", "mobileDownLoadLinkIos": "Download mobile app for iOS", diff --git a/react/features/base/react/components/web/MeetingsList.js b/react/features/base/react/components/web/MeetingsList.js index 242784e0f..f9b8f7d1b 100644 --- a/react/features/base/react/components/web/MeetingsList.js +++ b/react/features/base/react/components/web/MeetingsList.js @@ -105,17 +105,14 @@ class MeetingsList extends Component { * @returns {React.ReactNode} */ render() { - const { listEmptyComponent, meetings, t } = this.props; + const { listEmptyComponent, meetings } = this.props; /** * If there are no recent meetings we don't want to display anything. */ if (meetings) { return ( - + { meetings.length === 0 ? listEmptyComponent @@ -237,23 +234,16 @@ class MeetingsList extends Component { return ( - - - { _toDateString(date) } - - - { _toTimeString(time) } - - + onClick = { onPress }> - + { title } { @@ -269,6 +259,14 @@ class MeetingsList extends Component { ) : null } + + + { _toDateString(date) } + + + { _toTimeString(time) } + + { elementAfter || null } 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} +
+ )}
diff --git a/react/features/calendar-sync/components/CalendarList.web.js b/react/features/calendar-sync/components/CalendarList.web.js index 14cbcb198..9a293c84d 100644 --- a/react/features/calendar-sync/components/CalendarList.web.js +++ b/react/features/calendar-sync/components/CalendarList.web.js @@ -202,7 +202,8 @@ class CalendarList extends AbstractPage { className = 'meetings-list-empty-button' onClick = { this._onOpenSettings } onKeyPress = { this._onKeyPressOpenSettings } - role = 'button'> + role = 'button' + tabIndex = { 0 }> diff --git a/react/features/chat/components/web/Chat.js b/react/features/chat/components/web/Chat.js index aab8f3365..486e8e227 100644 --- a/react/features/chat/components/web/Chat.js +++ b/react/features/chat/components/web/Chat.js @@ -134,35 +134,38 @@ class Chat extends AbstractChat { _renderChat() { const { _isPollsEnabled, _isPollsTabFocused } = this.props; - if (_isPollsTabFocused) { - return ( - <> - { _isPollsEnabled && this._renderTabs() } -
- -
- - - ); - } - return ( <> { _isPollsEnabled && this._renderTabs() }
+ className = { clsx( + 'chat-panel', + !_isPollsEnabled && 'chat-panel-no-tabs', + _isPollsTabFocused && 'hide' + ) } + id = { `${CHAT_TABS.CHAT}-panel` } + role = 'tabpanel' + tabIndex = { 0 }>
+ { _isPollsEnabled && ( + <> +
+ +
+ + + )} ); } @@ -185,11 +188,13 @@ class Chat extends AbstractChat { accessibilityLabel: t('chat.tabs.chat'), countBadge: _isPollsTabFocused && _nbUnreadMessages > 0 ? _nbUnreadMessages : undefined, id: CHAT_TABS.CHAT, + controlsId: `${CHAT_TABS.CHAT}-panel`, label: t('chat.tabs.chat') }, { accessibilityLabel: t('chat.tabs.polls'), countBadge: !_isPollsTabFocused && _nbUnreadPolls > 0 ? _nbUnreadPolls : undefined, id: CHAT_TABS.POLLS, + controlsId: `${CHAT_TABS.POLLS}-panel`, label: t('chat.tabs.polls') } ] } /> diff --git a/react/features/chat/components/web/ChatInput.tsx b/react/features/chat/components/web/ChatInput.tsx index e4451551e..2a8b40923 100644 --- a/react/features/chat/components/web/ChatInput.tsx +++ b/react/features/chat/components/web/ChatInput.tsx @@ -115,7 +115,6 @@ class ChatInput extends Component {
)} { * @inheritdoc */ render() { - const { selectedTab, selectedSource, sources } = this.state; + const { selectedTab, selectedSource, sources, types } = this.state; return ( { size = 'large' titleKey = 'dialog.shareYourScreen'> { this._renderTabs() } - + {types.map(type => ( +
+ {selectedTab === type && ( + + )} +
+ ))} +
); } @@ -348,17 +361,18 @@ class DesktopPicker extends PureComponent { type => { return { accessibilityLabel: t(TAB_LABELS[type as keyof typeof TAB_LABELS]), - id: type, + id: `${type}-tab`, + controlsId: `${type}-panel`, label: t(TAB_LABELS[type as keyof typeof TAB_LABELS]) }; }); return ( ); } diff --git a/react/features/polls/components/web/PollsPane.tsx b/react/features/polls/components/web/PollsPane.tsx index bec99b053..c1cf624ee 100644 --- a/react/features/polls/components/web/PollsPane.tsx +++ b/react/features/polls/components/web/PollsPane.tsx @@ -40,7 +40,6 @@ const PollsPane = ({ createMode, onCreate, setCreateMode, t }: AbstractProps) =>
+ ))} +
+ {tabs.map((tab, index) => ( +
+ {tab.content} +
+ ))} + + ) + : ( + <> +

{accessibilityLabel}

+
{tabs[0].content}
+ + ) + } +
+ ); +}; + +export default Tabs; diff --git a/react/features/welcome/components/WelcomePage.web.js b/react/features/welcome/components/WelcomePage.web.js index b2c0a86e4..8bc783836 100644 --- a/react/features/welcome/components/WelcomePage.web.js +++ b/react/features/welcome/components/WelcomePage.web.js @@ -49,8 +49,7 @@ class WelcomePage extends AbstractWelcomePage { ...this.state, generateRoomnames: - interfaceConfig.GENERATE_ROOMNAMES_ON_WELCOME_PAGE, - selectedTab: 0 + interfaceConfig.GENERATE_ROOMNAMES_ON_WELCOME_PAGE }; /** @@ -114,7 +113,6 @@ class WelcomePage extends AbstractWelcomePage { this._setRoomInputRef = this._setRoomInputRef.bind(this); this._setAdditionalToolbarContentRef = this._setAdditionalToolbarContentRef.bind(this); - this._onTabSelected = this._onTabSelected.bind(this); this._renderFooter = this._renderFooter.bind(this); } @@ -326,18 +324,6 @@ class WelcomePage extends AbstractWelcomePage { super._onRoomChange(event.target.value); } - /** - * Callback invoked when the desired tab to display should be changed. - * - * @param {number} tabIndex - The index of the tab within the array of - * displayed tabs. - * @private - * @returns {void} - */ - _onTabSelected(tabIndex) { - this.setState({ selectedTab: tabIndex }); - } - /** * Renders the footer. * @@ -405,6 +391,7 @@ class WelcomePage extends AbstractWelcomePage { if (_calendarEnabled) { tabs.push({ + id: 'calendar', label: t('welcomepage.upcomingMeetings'), content: }); @@ -412,6 +399,7 @@ class WelcomePage extends AbstractWelcomePage { if (_recentListEnabled) { tabs.push({ + id: 'recent', label: t('welcomepage.recentMeetings'), content: }); @@ -423,9 +411,9 @@ class WelcomePage extends AbstractWelcomePage { return ( ); + accessibilityLabel = { t('welcomepage.meetingsAccessibilityLabel') } + tabs = { tabs } /> + ); } /**