Use tabs ARIA design pattern when using tabbed UI (#12994)

feat(a11y): use tabs ARIA design pattern when using tabbed UI
This commit is contained in:
Emmanuel Pelletier 2023-03-06 16:13:29 +01:00 committed by GitHub
parent 0d0bec3aad
commit f727b9295f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 338 additions and 239 deletions

View File

@ -82,6 +82,7 @@
} }
.left-column { .left-column {
order: -1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
flex-grow: 0; flex-grow: 0;
@ -92,6 +93,7 @@
.right-column { .right-column {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: flex-start;
flex-grow: 1; flex-grow: 1;
padding-left: 16px; padding-left: 16px;
padding-top: 13px; padding-top: 13px;
@ -99,11 +101,11 @@
} }
.title { .title {
font-size: 12px; font-size: 12px;
font-weight: 600; font-weight: 600;
line-height: 16px; line-height: 16px;
padding-bottom: 4px; margin-bottom: 4px;
} }
.subtitle { .subtitle {
color: #5E6D7A; color: #5E6D7A;
@ -125,8 +127,7 @@
cursor: pointer; cursor: pointer;
} }
&.with-click-handler:hover, &.with-click-handler:hover {
&.with-click-handler:focus {
background-color: #c7ddff; background-color: #c7ddff;
} }

View File

@ -1,3 +1,3 @@
#polls-panel { .polls-panel {
height: calc(100% - 119px); height: calc(100% - 119px);
} }

View File

@ -167,7 +167,7 @@ body.welcome-page {
margin: 4px; margin: 4px;
display: $welcomePageTabButtonsDisplay; display: $welcomePageTabButtonsDisplay;
.tab { [role="tab"] {
background-color: #c7ddff; background-color: #c7ddff;
border-radius: 7px; border-radius: 7px;
cursor: pointer; cursor: pointer;
@ -176,8 +176,10 @@ body.welcome-page {
margin: 2px; margin: 2px;
padding: 7px 0; padding: 7px 0;
text-align: center; text-align: center;
color: inherit;
border: 0;
&.selected { &[aria-selected="true"] {
background-color: #FFF; background-color: #FFF;
} }
} }

View File

@ -240,7 +240,9 @@
"WaitingForHostTitle": "Waiting for the host ...", "WaitingForHostTitle": "Waiting for the host ...",
"Yes": "Yes", "Yes": "Yes",
"accessibilityLabel": { "accessibilityLabel": {
"liveStreaming": "Live Stream" "close": "Close dialog",
"liveStreaming": "Live Stream",
"sharingTabs": "Sharing options"
}, },
"add": "Add", "add": "Add",
"addMeetingNote": "Add a note about this meeting", "addMeetingNote": "Add a note about this meeting",
@ -1387,6 +1389,7 @@
"microsoftLogo": "Microsoft logo", "microsoftLogo": "Microsoft logo",
"policyLogo": "Policy logo" "policyLogo": "Policy logo"
}, },
"meetingsAccessibilityLabel": "Meetings",
"mobileDownLoadLinkAndroid": "Download mobile app for Android", "mobileDownLoadLinkAndroid": "Download mobile app for Android",
"mobileDownLoadLinkFDroid": "Download mobile app for F-Droid", "mobileDownLoadLinkFDroid": "Download mobile app for F-Droid",
"mobileDownLoadLinkIos": "Download mobile app for iOS", "mobileDownLoadLinkIos": "Download mobile app for iOS",

View File

@ -105,17 +105,14 @@ class MeetingsList extends Component<Props> {
* @returns {React.ReactNode} * @returns {React.ReactNode}
*/ */
render() { 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 there are no recent meetings we don't want to display anything.
*/ */
if (meetings) { if (meetings) {
return ( return (
<Container <Container className = 'meetings-list'>
aria-label = { t('welcomepage.recentList') }
className = 'meetings-list'
tabIndex = '-1'>
{ {
meetings.length === 0 meetings.length === 0
? listEmptyComponent ? listEmptyComponent
@ -237,23 +234,16 @@ class MeetingsList extends Component<Props> {
return ( return (
<Container <Container
aria-label = { title }
className = { rootClassName } className = { rootClassName }
key = { index } key = { index }
onClick = { onPress } onClick = { onPress }>
onKeyPress = { onKeyPress }
role = 'button'
tabIndex = { 0 }>
<Container className = 'left-column'>
<Text className = 'title'>
{ _toDateString(date) }
</Text>
<Text className = 'subtitle'>
{ _toTimeString(time) }
</Text>
</Container>
<Container className = 'right-column'> <Container className = 'right-column'>
<Text className = 'title'> <Text
className = 'title'
onClick = { onPress }
onKeyPress = { onKeyPress }
role = 'button'
tabIndex = { 0 }>
{ title } { title }
</Text> </Text>
{ {
@ -269,6 +259,14 @@ class MeetingsList extends Component<Props> {
</Text>) : null </Text>) : null
} }
</Container> </Container>
<Container className = 'left-column'>
<Text className = 'title'>
{ _toDateString(date) }
</Text>
<Text className = 'subtitle'>
{ _toTimeString(time) }
</Text>
</Container>
<Container className = 'actions'> <Container className = 'actions'>
{ elementAfter || null } { elementAfter || null }

View File

@ -26,6 +26,13 @@ export interface IProps {
*/ */
className?: string; 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. * Custom icon. If used, the icon prop is ignored.
* Used to allow custom children instead of just the default icons. * Used to allow custom children instead of just the default icons.
@ -55,7 +62,7 @@ export interface IProps {
/** /**
* Keydown handler. * Keydown handler.
*/ */
onKeyDown?: (e?: React.KeyboardEvent) => void; onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
/** /**
* Keypress handler. * Keypress handler.
@ -67,6 +74,11 @@ export interface IProps {
*/ */
overflowType?: TEXT_OVERFLOW_TYPES; 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. * Whether the item is marked as selected.
*/ */
@ -150,6 +162,7 @@ const ContextMenuItem = ({
accessibilityLabel, accessibilityLabel,
children, children,
className, className,
controls,
customIcon, customIcon,
disabled, disabled,
id, id,
@ -158,6 +171,7 @@ const ContextMenuItem = ({
onKeyDown, onKeyDown,
onKeyPress, onKeyPress,
overflowType, overflowType,
role = 'button',
selected, selected,
testId, testId,
text, text,
@ -167,8 +181,10 @@ const ContextMenuItem = ({
return ( return (
<div <div
aria-controls = { controls }
aria-disabled = { disabled } aria-disabled = { disabled }
aria-label = { accessibilityLabel } aria-label = { accessibilityLabel }
aria-selected = { role === 'tab' ? selected : undefined }
className = { cx(styles.contextMenuItem, className = { cx(styles.contextMenuItem,
_overflowDrawer && styles.contextMenuItemDrawer, _overflowDrawer && styles.contextMenuItemDrawer,
disabled && styles.contextMenuItemDisabled, disabled && styles.contextMenuItemDisabled,
@ -181,8 +197,11 @@ const ContextMenuItem = ({
onClick = { disabled ? undefined : onClick } onClick = { disabled ? undefined : onClick }
onKeyDown = { disabled ? undefined : onKeyDown } onKeyDown = { disabled ? undefined : onKeyDown }
onKeyPress = { disabled ? undefined : onKeyPress } onKeyPress = { disabled ? undefined : onKeyPress }
role = 'button' role = { role }
tabIndex = { disabled ? undefined : 0 }> tabIndex = { role === 'tab'
? selected ? 0 : -1
: disabled ? undefined : 0
}>
{customIcon ? customIcon {customIcon ? customIcon
: icon && <Icon : icon && <Icon
className = { styles.contextMenuItemIcon } className = { styles.contextMenuItemIcon }

View File

@ -131,7 +131,7 @@ const Dialog = ({
</p> </p>
{!hideCloseButton && ( {!hideCloseButton && (
<ClickableIcon <ClickableIcon
accessibilityLabel = { t('dialog.close') } accessibilityLabel = { t('dialog.accessibilityLabel.close') }
icon = { IconCloseLarge } icon = { IconCloseLarge }
id = 'modal-header-close-button' id = 'modal-header-close-button'
onClick = { onClose } /> onClick = { onClose } />

View File

@ -1,4 +1,5 @@
import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react'; import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react';
import { MoveFocusInside } from 'react-focus-lock';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux'; import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui'; import { makeStyles } from 'tss-react/mui';
@ -89,10 +90,6 @@ const useStyles = makeStyles()(theme => {
} }
}, },
closeButtonContainer: {
paddingBottom: theme.spacing(4)
},
buttonContainer: { buttonContainer: {
width: '100%', width: '100%',
boxSizing: 'border-box', boxSizing: 'border-box',
@ -109,6 +106,7 @@ const useStyles = makeStyles()(theme => {
backContainer: { backContainer: {
display: 'flex', display: 'flex',
flexDirection: 'row-reverse',
alignItems: 'center', alignItems: 'center',
'& > button': { '& > button': {
@ -127,6 +125,11 @@ const useStyles = makeStyles()(theme => {
} }
}, },
header: {
order: -1,
paddingBottom: theme.spacing(4)
},
footer: { footer: {
justifyContent: 'flex-end', justifyContent: 'flex-end',
paddingTop: theme.spacing(4), paddingTop: theme.spacing(4),
@ -168,6 +171,7 @@ const DialogWithTabs = ({
const dispatch = useDispatch(); const dispatch = useDispatch();
const { t } = useTranslation(); const { t } = useTranslation();
const [ selectedTab, setSelectedTab ] = useState<string | undefined>(defaultTab ?? tabs[0].name); const [ selectedTab, setSelectedTab ] = useState<string | undefined>(defaultTab ?? tabs[0].name);
const [ userSelected, setUserSelected ] = useState(false);
const [ tabStates, setTabStates ] = useState(tabs.map(tab => tab.props)); const [ tabStates, setTabStates ] = useState(tabs.map(tab => tab.props));
const clientWidth = useSelector((state: IReduxState) => state['features/base/responsive-ui'].clientWidth); const clientWidth = useSelector((state: IReduxState) => state['features/base/responsive-ui'].clientWidth);
const [ isMobile, setIsMobile ] = useState(false); const [ isMobile, setIsMobile ] = useState(false);
@ -188,18 +192,58 @@ const DialogWithTabs = ({
} }
}, [ isMobile ]); }, [ isMobile ]);
const back = useCallback(() => { const onUserSelection = useCallback((tabName?: string) => {
setSelectedTab(undefined); 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<HTMLElement>(isMobile
? `.${classes.title}`
: `#${`dialogtab-button-${selectedTab}`}`
)?.focus();
setUserSelected(false);
}
}, [ isMobile, userSelected, selectedTab ]);
const onClose = useCallback(() => { const onClose = useCallback(() => {
dispatch(hideDialog()); dispatch(hideDialog());
}, []); }, []);
const onClick = useCallback((tabName: string) => () => { const onClick = useCallback((tabName: string) => () => {
setSelectedTab(tabName); onUserSelection(tabName);
}, []); }, []);
const onTabKeyDown = useCallback((index: number) => (event: React.KeyboardEvent<HTMLDivElement>) => {
let newTab: IDialogTab<any> | 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<HTMLDivElement>) => {
if (event.key === ' ' || event.key === 'Enter') {
onUserSelection(tabName);
}
}, [ classes.contentContainer ]);
const getTabProps = (tabId: number) => { const getTabProps = (tabId: number) => {
const tabConfiguration = tabs[tabId]; const tabConfiguration = tabs[tabId];
const currentTabState = tabStates[tabId]; const currentTabState = tabStates[tabId];
@ -256,7 +300,7 @@ const DialogWithTabs = ({
const closeIcon = useMemo(() => ( const closeIcon = useMemo(() => (
<ClickableIcon <ClickableIcon
accessibilityLabel = { t('dialog.close') } accessibilityLabel = { t('dialog.accessibilityLabel.close') }
icon = { IconCloseLarge } icon = { IconCloseLarge }
id = 'modal-header-close-button' id = 'modal-header-close-button'
onClick = { onClose } /> onClick = { onClose } />
@ -268,21 +312,39 @@ const DialogWithTabs = ({
onClose = { onClose } onClose = { onClose }
size = 'large'> size = 'large'>
{(!isMobile || !selectedTab) && ( {(!isMobile || !selectedTab) && (
<div className = { classes.sidebar }> <div
aria-orientation = 'vertical'
className = { classes.sidebar }
role = { isMobile ? undefined : 'tablist' }>
<div className = { classes.titleContainer }> <div className = { classes.titleContainer }>
<h2 className = { classes.title }>{t(titleKey ?? '')}</h2> <MoveFocusInside>
<h2
className = { classes.title }
tabIndex = { -1 }>
{t(titleKey ?? '')}
</h2>
</MoveFocusInside>
{isMobile && closeIcon} {isMobile && closeIcon}
</div> </div>
{tabs.map(tab => { {tabs.map((tab, index) => {
const label = t(tab.labelKey); 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 ( return (
<ContextMenuItem <ContextMenuItem
accessibilityLabel = { label } accessibilityLabel = { label }
className = { cx(isMobile && classes.menuItemMobile) } className = { cx(isMobile && classes.menuItemMobile) }
controls = { isMobile ? undefined : `dialogtab-content-${tab.name}` }
icon = { tab.icon } icon = { tab.icon }
id = { `dialogtab-button-${tab.name}` }
key = { tab.name } key = { tab.name }
onClick = { onClick(tab.name) } onClick = { onClick(tab.name) }
onKeyDown = { isMobile ? onMobileKeyDown(tab.name) : onTabKeyDown(index) }
role = { isMobile ? undefined : 'tab' }
selected = { tab.name === selectedTab } selected = { tab.name === selectedTab }
text = { label } /> text = { label } />
); );
@ -290,25 +352,45 @@ const DialogWithTabs = ({
</div> </div>
)} )}
{(!isMobile || selectedTab) && ( {(!isMobile || selectedTab) && (
<div className = { classes.contentContainer }> <div
<div className = { cx(classes.buttonContainer, classes.closeButtonContainer) }> className = { classes.contentContainer }
{isMobile && ( tabIndex = { isMobile ? -1 : undefined }>
{/* DOM order is important for keyboard users: show whole heading first when on mobile… */}
{isMobile && (
<div className = { cx(classes.buttonContainer, classes.header) }>
<span className = { classes.backContainer }> <span className = { classes.backContainer }>
<h2
className = { classes.title }
tabIndex = { -1 }>
{(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)}
</h2>
<ClickableIcon <ClickableIcon
accessibilityLabel = { t('dialog.Back') } accessibilityLabel = { t('dialog.Back') }
icon = { IconArrowBack } icon = { IconArrowBack }
id = 'modal-header-back-button' id = 'modal-header-back-button'
onClick = { back } /> onClick = { back } />
<h2 className = { classes.title }>
{(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)}
</h2>
</span> </span>
)} {closeIcon}
{closeIcon} </div>
</div> )}
<div className = { classes.content }> {tabs.map(tab => (
{selectedTabComponent} <div
</div> aria-labelledby = { isMobile ? undefined : `${tab.name}-button` }
className = { cx(classes.content, tab.name !== selectedTab && 'hide') }
id = { `dialogtab-content-${tab.name}` }
key = { tab.name }
role = { isMobile ? undefined : 'tabpanel' }
tabIndex = { isMobile ? -1 : 0 }>
{ tab.name === selectedTab && selectedTabComponent }
</div>
))}
{/* 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 && (
<div className = { cx(classes.buttonContainer, classes.header) }>
{closeIcon}
</div>
)}
<div <div
className = { cx(classes.buttonContainer, classes.footer) }> className = { cx(classes.buttonContainer, classes.footer) }>
<Button <Button

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useEffect } from 'react';
import { makeStyles } from 'tss-react/mui'; import { makeStyles } from 'tss-react/mui';
import { isMobileBrowser } from '../../../environment/utils'; import { isMobileBrowser } from '../../../environment/utils';
@ -11,6 +11,7 @@ interface ITabProps {
selected: string; selected: string;
tabs: Array<{ tabs: Array<{
accessibilityLabel: string; accessibilityLabel: string;
controlsId: string;
countBadge?: number; countBadge?: number;
disabled?: boolean; disabled?: boolean;
id: string; id: string;
@ -87,26 +88,52 @@ const Tabs = ({
}: ITabProps) => { }: ITabProps) => {
const { classes, cx } = useStyles(); const { classes, cx } = useStyles();
const isMobile = isMobileBrowser(); const isMobile = isMobileBrowser();
const onClick = useCallback(id => () => {
const handleChange = useCallback((e: React.MouseEvent<HTMLButtonElement>) => { onChange(id);
onChange(e.currentTarget.id);
}, []); }, []);
const onKeyDown = useCallback((index: number) => (event: React.KeyboardEvent<HTMLButtonElement>) => {
let newIndex: number | null = null;
if (event.key === 'ArrowLeft') {
event.preventDefault();
newIndex = index === 0 ? tabs.length - 1 : index - 1;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
newIndex = index === tabs.length - 1 ? 0 : index + 1;
}
if (newIndex !== null) {
onChange(tabs[newIndex].id);
}
}, [ tabs ]);
useEffect(() => {
// this test is needed to make sure the effect is triggered because of user actually changing tab
if (document.activeElement?.getAttribute('role') === 'tab') {
document.querySelector<HTMLButtonElement>(`#${selected}`)?.focus();
}
}, [ selected ]);
return ( return (
<div <div
aria-label = { accessibilityLabel } aria-label = { accessibilityLabel }
className = { cx(classes.container, className) } className = { cx(classes.container, className) }
role = 'tablist'> role = 'tablist'>
{tabs.map(tab => ( {tabs.map((tab, index) => (
<button <button
aria-controls = { tab.controlsId }
aria-label = { tab.accessibilityLabel } aria-label = { tab.accessibilityLabel }
aria-selected = { selected === tab.id } aria-selected = { selected === tab.id }
className = { cx(classes.tab, selected === tab.id && 'selected', isMobile && 'is-mobile') } className = { cx(classes.tab, selected === tab.id && 'selected', isMobile && 'is-mobile') }
disabled = { tab.disabled } disabled = { tab.disabled }
id = { tab.id } id = { tab.id }
key = { tab.id } key = { tab.id }
onClick = { handleChange } onClick = { onClick(tab.id) }
role = 'tab'> onKeyDown = { onKeyDown(index) }
role = 'tab'
tabIndex = { selected === tab.id ? undefined : -1 }>
{tab.label} {tab.label}
{tab.countBadge && <span className = { classes.badge }>{tab.countBadge}</span>} {tab.countBadge && <span className = { classes.badge }>{tab.countBadge}</span>}
</button> </button>

View File

@ -202,7 +202,8 @@ class CalendarList extends AbstractPage<Props> {
className = 'meetings-list-empty-button' className = 'meetings-list-empty-button'
onClick = { this._onOpenSettings } onClick = { this._onOpenSettings }
onKeyPress = { this._onKeyPressOpenSettings } onKeyPress = { this._onKeyPressOpenSettings }
role = 'button'> role = 'button'
tabIndex = { 0 }>
<Icon <Icon
className = 'meetings-list-empty-icon' className = 'meetings-list-empty-icon'
src = { IconCalendar } /> src = { IconCalendar } />

View File

@ -134,35 +134,38 @@ class Chat extends AbstractChat<Props> {
_renderChat() { _renderChat() {
const { _isPollsEnabled, _isPollsTabFocused } = this.props; const { _isPollsEnabled, _isPollsTabFocused } = this.props;
if (_isPollsTabFocused) {
return (
<>
{ _isPollsEnabled && this._renderTabs() }
<div
aria-labelledby = { CHAT_TABS.POLLS }
id = 'polls-panel'
role = 'tabpanel'>
<PollsPane />
</div>
<KeyboardAvoider />
</>
);
}
return ( return (
<> <>
{ _isPollsEnabled && this._renderTabs() } { _isPollsEnabled && this._renderTabs() }
<div <div
aria-labelledby = { CHAT_TABS.CHAT } aria-labelledby = { CHAT_TABS.CHAT }
className = { clsx('chat-panel', !_isPollsEnabled && 'chat-panel-no-tabs') } className = { clsx(
id = 'chat-panel' 'chat-panel',
role = 'tabpanel'> !_isPollsEnabled && 'chat-panel-no-tabs',
_isPollsTabFocused && 'hide'
) }
id = { `${CHAT_TABS.CHAT}-panel` }
role = 'tabpanel'
tabIndex = { 0 }>
<MessageContainer <MessageContainer
messages = { this.props._messages } /> messages = { this.props._messages } />
<MessageRecipient /> <MessageRecipient />
<ChatInput <ChatInput
onSend = { this._onSendMessage } /> onSend = { this._onSendMessage } />
</div> </div>
{ _isPollsEnabled && (
<>
<div
aria-labelledby = { CHAT_TABS.POLLS }
className = { clsx('polls-panel', !_isPollsTabFocused && 'hide') }
id = { `${CHAT_TABS.POLLS}-panel` }
role = 'tabpanel'
tabIndex = { 0 }>
<PollsPane />
</div>
<KeyboardAvoider />
</>
)}
</> </>
); );
} }
@ -185,11 +188,13 @@ class Chat extends AbstractChat<Props> {
accessibilityLabel: t('chat.tabs.chat'), accessibilityLabel: t('chat.tabs.chat'),
countBadge: _isPollsTabFocused && _nbUnreadMessages > 0 ? _nbUnreadMessages : undefined, countBadge: _isPollsTabFocused && _nbUnreadMessages > 0 ? _nbUnreadMessages : undefined,
id: CHAT_TABS.CHAT, id: CHAT_TABS.CHAT,
controlsId: `${CHAT_TABS.CHAT}-panel`,
label: t('chat.tabs.chat') label: t('chat.tabs.chat')
}, { }, {
accessibilityLabel: t('chat.tabs.polls'), accessibilityLabel: t('chat.tabs.polls'),
countBadge: !_isPollsTabFocused && _nbUnreadPolls > 0 ? _nbUnreadPolls : undefined, countBadge: !_isPollsTabFocused && _nbUnreadPolls > 0 ? _nbUnreadPolls : undefined,
id: CHAT_TABS.POLLS, id: CHAT_TABS.POLLS,
controlsId: `${CHAT_TABS.POLLS}-panel`,
label: t('chat.tabs.polls') label: t('chat.tabs.polls')
} }
] } /> ] } />

View File

@ -115,7 +115,6 @@ class ChatInput extends Component<IProps, IState> {
</div> </div>
)} )}
<Input <Input
autoFocus = { true }
className = 'chat-input' className = 'chat-input'
icon = { this.props._areSmileysDisabled ? undefined : IconFaceSmile } icon = { this.props._areSmileysDisabled ? undefined : IconFaceSmile }
iconClick = { this._toggleSmileysPanel } iconClick = { this._toggleSmileysPanel }

View File

@ -191,7 +191,7 @@ class DesktopPicker extends PureComponent<IProps, IState> {
* @inheritdoc * @inheritdoc
*/ */
render() { render() {
const { selectedTab, selectedSource, sources } = this.state; const { selectedTab, selectedSource, sources, types } = this.state;
return ( return (
<Dialog <Dialog
@ -204,14 +204,27 @@ class DesktopPicker extends PureComponent<IProps, IState> {
size = 'large' size = 'large'
titleKey = 'dialog.shareYourScreen'> titleKey = 'dialog.shareYourScreen'>
{ this._renderTabs() } { this._renderTabs() }
<DesktopPickerPane {types.map(type => (
key = { selectedTab } <div
onClick = { this._onPreviewClick } aria-labelledby = { `${type}-button` }
onDoubleClick = { this._onSubmit } className = { selectedTab === type ? undefined : 'hide' }
onShareAudioChecked = { this._onShareAudioChecked } id = { `${type}-panel` }
selectedSourceId = { selectedSource.id } key = { type }
sources = { sources[selectedTab as keyof typeof sources] } role = 'tabpanel'
type = { selectedTab } /> tabIndex = { 0 }>
{selectedTab === type && (
<DesktopPickerPane
key = { selectedTab }
onClick = { this._onPreviewClick }
onDoubleClick = { this._onSubmit }
onShareAudioChecked = { this._onShareAudioChecked }
selectedSourceId = { selectedSource.id }
sources = { sources[selectedTab as keyof typeof sources] }
type = { selectedTab } />
)}
</div>
))}
</Dialog> </Dialog>
); );
} }
@ -348,17 +361,18 @@ class DesktopPicker extends PureComponent<IProps, IState> {
type => { type => {
return { return {
accessibilityLabel: t(TAB_LABELS[type as keyof typeof TAB_LABELS]), 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]) label: t(TAB_LABELS[type as keyof typeof TAB_LABELS])
}; };
}); });
return ( return (
<Tabs <Tabs
accessibilityLabel = '' accessibilityLabel = { t('dialog.sharingTabs') }
className = 'desktop-picker-tabs-container' className = 'desktop-picker-tabs-container'
onChange = { this._onTabSelected } onChange = { this._onTabSelected }
selected = { this.state.selectedTab } selected = { `${this.state.selectedTab}-tab` }
tabs = { tabs } />); tabs = { tabs } />);
} }

View File

@ -40,7 +40,6 @@ const PollsPane = ({ createMode, onCreate, setCreateMode, t }: AbstractProps) =>
<div className = { classes.footer }> <div className = { classes.footer }>
<Button <Button
accessibilityLabel = { t('polls.create.create') } accessibilityLabel = { t('polls.create.create') }
autoFocus = { true }
fullWidth = { true } fullWidth = { true }
labelKey = { 'polls.create.create' } labelKey = { 'polls.create.create' }
onClick = { onCreate } /> onClick = { onCreate } />

View File

@ -1,76 +0,0 @@
// @flow
import React, { Component } from 'react';
/**
* The type of the React {@code Component} props of {@link Tab}.
*/
type Props = {
/**
* The index of the tab.
*/
index: number,
/**
* Indicates if the tab is selected or not.
*/
isSelected: boolean,
/**
* The label of the tab.
*/
label: string,
/**
* Handler for selecting the tab.
*/
onSelect: Function
}
/**
* A React component that implements tabs.
*
*/
export default class Tab extends Component<Props> {
/**
* Initializes a new {@code Tab} instance.
*
* @inheritdoc
*/
constructor(props: Props) {
super(props);
this._onSelect = this._onSelect.bind(this);
}
_onSelect: () => void;
/**
* Selects a tab.
*
* @returns {void}
*/
_onSelect() {
const { index, onSelect } = this.props;
onSelect(index);
}
/**
* Implements the React Components's render method.
*
* @inheritdoc
*/
render() {
const { index, isSelected, label } = this.props;
const className = `tab${isSelected ? ' selected' : ''}`;
return (
<div
className = { className }
key = { index }
onClick = { this._onSelect }>
{ label }
</div>);
}
}

View File

@ -1,7 +1,5 @@
// @flow // @flow
import React, { Component } from 'react'; import React, { useCallback, useEffect, useState } from 'react';
import Tab from './Tab';
/** /**
* The type of the React {@code Component} props of {@link Tabs}. * The type of the React {@code Component} props of {@link Tabs}.
@ -9,14 +7,10 @@ import Tab from './Tab';
type Props = { type Props = {
/** /**
* Handler for selecting the tab. * Accessibility label for the tabs container.
*
*/ */
onSelect: Function, accessibilityLabel: string,
/**
* The index of the selected tab.
*/
selected: number,
/** /**
* Tabs information. * Tabs information.
@ -27,44 +21,87 @@ type Props = {
/** /**
* A React component that implements tabs. * A React component that implements tabs.
* *
* @returns {ReactElement} The component.
*/ */
export default class Tabs extends Component<Props> { const Tabs = ({ accessibilityLabel, tabs }: Props) => {
static defaultProps = { const [ current, setCurrent ] = useState(0);
tabs: [],
selected: 0
};
/** const onClick = useCallback(index => event => {
* Implements the React Components's render method. event.preventDefault();
* setCurrent(index);
* @inheritdoc }, []);
*/
render() {
const { onSelect, selected, tabs } = this.props;
const { content = null } = tabs.length
? tabs[Math.min(selected, tabs.length - 1)]
: {};
return ( const onKeyDown = useCallback(index => event => {
<div className = 'tab-container'> let newIndex = null;
{ tabs.length > 1 ? (
<div className = 'tab-buttons'> if (event.key === 'ArrowLeft') {
{ event.preventDefault();
tabs.map((tab, index) => ( newIndex = index === 0 ? tabs.length - 1 : index - 1;
<Tab }
index = { index }
isSelected = { index === selected } if (event.key === 'ArrowRight') {
key = { index } event.preventDefault();
label = { tab.label } newIndex = index === tabs.length - 1 ? 0 : index + 1;
onSelect = { onSelect } /> }
))
} if (newIndex !== null) {
</div>) : null setCurrent(newIndex);
} }
<div className = 'tab-content'> }, [ tabs ]);
{ content }
</div> useEffect(() => {
</div> // this test is needed to make sure the effect is triggered because of user actually changing tab
); if (document.activeElement?.getAttribute('role') === 'tab') {
} document.querySelector(`#${`${tabs[current].id}-tab`}`)?.focus();
} }
}, [ current, tabs ]);
return (
<div className = 'tab-container'>
{ tabs.length > 1
? (
<>
<div
aria-label = { accessibilityLabel }
className = 'tab-buttons'
role = 'tablist'>
{tabs.map((tab, index) => (
<button
aria-controls = { `${tab.id}-panel` }
aria-selected = { current === index ? 'true' : 'false' }
id = { `${tab.id}-tab` }
key = { tab.id }
onClick = { onClick(index) }
onKeyDown = { onKeyDown(index) }
role = 'tab'
tabIndex = { current === index ? undefined : -1 }>
{tab.label}
</button>
))}
</div>
{tabs.map((tab, index) => (
<div
aria-labelledby = { `${tab.id}-tab` }
className = { current === index ? 'tab-content' : 'hide' }
id = { `${tab.id}-panel` }
key = { tab.id }
role = 'tabpanel'
tabIndex = { 0 }>
{tab.content}
</div>
))}
</>
)
: (
<>
<h2 className = 'sr-only'>{accessibilityLabel}</h2>
<div className = 'tab-content'>{tabs[0].content}</div>
</>
)
}
</div>
);
};
export default Tabs;

View File

@ -49,8 +49,7 @@ class WelcomePage extends AbstractWelcomePage {
...this.state, ...this.state,
generateRoomnames: generateRoomnames:
interfaceConfig.GENERATE_ROOMNAMES_ON_WELCOME_PAGE, interfaceConfig.GENERATE_ROOMNAMES_ON_WELCOME_PAGE
selectedTab: 0
}; };
/** /**
@ -114,7 +113,6 @@ class WelcomePage extends AbstractWelcomePage {
this._setRoomInputRef = this._setRoomInputRef.bind(this); this._setRoomInputRef = this._setRoomInputRef.bind(this);
this._setAdditionalToolbarContentRef this._setAdditionalToolbarContentRef
= this._setAdditionalToolbarContentRef.bind(this); = this._setAdditionalToolbarContentRef.bind(this);
this._onTabSelected = this._onTabSelected.bind(this);
this._renderFooter = this._renderFooter.bind(this); this._renderFooter = this._renderFooter.bind(this);
} }
@ -326,18 +324,6 @@ class WelcomePage extends AbstractWelcomePage {
super._onRoomChange(event.target.value); 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. * Renders the footer.
* *
@ -405,6 +391,7 @@ class WelcomePage extends AbstractWelcomePage {
if (_calendarEnabled) { if (_calendarEnabled) {
tabs.push({ tabs.push({
id: 'calendar',
label: t('welcomepage.upcomingMeetings'), label: t('welcomepage.upcomingMeetings'),
content: <CalendarList /> content: <CalendarList />
}); });
@ -412,6 +399,7 @@ class WelcomePage extends AbstractWelcomePage {
if (_recentListEnabled) { if (_recentListEnabled) {
tabs.push({ tabs.push({
id: 'recent',
label: t('welcomepage.recentMeetings'), label: t('welcomepage.recentMeetings'),
content: <RecentList /> content: <RecentList />
}); });
@ -423,9 +411,9 @@ class WelcomePage extends AbstractWelcomePage {
return ( return (
<Tabs <Tabs
onSelect = { this._onTabSelected } accessibilityLabel = { t('welcomepage.meetingsAccessibilityLabel') }
selected = { this.state.selectedTab } tabs = { tabs } />
tabs = { tabs } />); );
} }
/** /**