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:
parent
0d0bec3aad
commit
f727b9295f
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -1,3 +1,3 @@
|
|||
#polls-panel {
|
||||
.polls-panel {
|
||||
height: calc(100% - 119px);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -105,17 +105,14 @@ class MeetingsList extends Component<Props> {
|
|||
* @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 (
|
||||
<Container
|
||||
aria-label = { t('welcomepage.recentList') }
|
||||
className = 'meetings-list'
|
||||
tabIndex = '-1'>
|
||||
<Container className = 'meetings-list'>
|
||||
{
|
||||
meetings.length === 0
|
||||
? listEmptyComponent
|
||||
|
@ -237,23 +234,16 @@ class MeetingsList extends Component<Props> {
|
|||
|
||||
return (
|
||||
<Container
|
||||
aria-label = { title }
|
||||
className = { rootClassName }
|
||||
key = { index }
|
||||
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>
|
||||
onClick = { onPress }>
|
||||
<Container className = 'right-column'>
|
||||
<Text className = 'title'>
|
||||
<Text
|
||||
className = 'title'
|
||||
onClick = { onPress }
|
||||
onKeyPress = { onKeyPress }
|
||||
role = 'button'
|
||||
tabIndex = { 0 }>
|
||||
{ title }
|
||||
</Text>
|
||||
{
|
||||
|
@ -269,6 +259,14 @@ class MeetingsList extends Component<Props> {
|
|||
</Text>) : null
|
||||
}
|
||||
</Container>
|
||||
<Container className = 'left-column'>
|
||||
<Text className = 'title'>
|
||||
{ _toDateString(date) }
|
||||
</Text>
|
||||
<Text className = 'subtitle'>
|
||||
{ _toTimeString(time) }
|
||||
</Text>
|
||||
</Container>
|
||||
<Container className = 'actions'>
|
||||
{ elementAfter || null }
|
||||
|
||||
|
|
|
@ -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<HTMLDivElement>) => 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 (
|
||||
<div
|
||||
aria-controls = { controls }
|
||||
aria-disabled = { disabled }
|
||||
aria-label = { accessibilityLabel }
|
||||
aria-selected = { role === 'tab' ? selected : undefined }
|
||||
className = { cx(styles.contextMenuItem,
|
||||
_overflowDrawer && styles.contextMenuItemDrawer,
|
||||
disabled && styles.contextMenuItemDisabled,
|
||||
|
@ -181,8 +197,11 @@ const ContextMenuItem = ({
|
|||
onClick = { disabled ? undefined : onClick }
|
||||
onKeyDown = { disabled ? undefined : onKeyDown }
|
||||
onKeyPress = { disabled ? undefined : onKeyPress }
|
||||
role = 'button'
|
||||
tabIndex = { disabled ? undefined : 0 }>
|
||||
role = { role }
|
||||
tabIndex = { role === 'tab'
|
||||
? selected ? 0 : -1
|
||||
: disabled ? undefined : 0
|
||||
}>
|
||||
{customIcon ? customIcon
|
||||
: icon && <Icon
|
||||
className = { styles.contextMenuItemIcon }
|
||||
|
|
|
@ -131,7 +131,7 @@ const Dialog = ({
|
|||
</p>
|
||||
{!hideCloseButton && (
|
||||
<ClickableIcon
|
||||
accessibilityLabel = { t('dialog.close') }
|
||||
accessibilityLabel = { t('dialog.accessibilityLabel.close') }
|
||||
icon = { IconCloseLarge }
|
||||
id = 'modal-header-close-button'
|
||||
onClick = { onClose } />
|
||||
|
|
|
@ -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<string | undefined>(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<HTMLElement>(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<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 tabConfiguration = tabs[tabId];
|
||||
const currentTabState = tabStates[tabId];
|
||||
|
@ -256,7 +300,7 @@ const DialogWithTabs = ({
|
|||
|
||||
const closeIcon = useMemo(() => (
|
||||
<ClickableIcon
|
||||
accessibilityLabel = { t('dialog.close') }
|
||||
accessibilityLabel = { t('dialog.accessibilityLabel.close') }
|
||||
icon = { IconCloseLarge }
|
||||
id = 'modal-header-close-button'
|
||||
onClick = { onClose } />
|
||||
|
@ -268,21 +312,39 @@ const DialogWithTabs = ({
|
|||
onClose = { onClose }
|
||||
size = 'large'>
|
||||
{(!isMobile || !selectedTab) && (
|
||||
<div className = { classes.sidebar }>
|
||||
<div
|
||||
aria-orientation = 'vertical'
|
||||
className = { classes.sidebar }
|
||||
role = { isMobile ? undefined : 'tablist' }>
|
||||
<div className = { classes.titleContainer }>
|
||||
<h2 className = { classes.title }>{t(titleKey ?? '')}</h2>
|
||||
<MoveFocusInside>
|
||||
<h2
|
||||
className = { classes.title }
|
||||
tabIndex = { -1 }>
|
||||
{t(titleKey ?? '')}
|
||||
</h2>
|
||||
</MoveFocusInside>
|
||||
{isMobile && closeIcon}
|
||||
</div>
|
||||
{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 (
|
||||
<ContextMenuItem
|
||||
accessibilityLabel = { label }
|
||||
className = { cx(isMobile && classes.menuItemMobile) }
|
||||
controls = { isMobile ? undefined : `dialogtab-content-${tab.name}` }
|
||||
icon = { tab.icon }
|
||||
id = { `dialogtab-button-${tab.name}` }
|
||||
key = { tab.name }
|
||||
onClick = { onClick(tab.name) }
|
||||
onKeyDown = { isMobile ? onMobileKeyDown(tab.name) : onTabKeyDown(index) }
|
||||
role = { isMobile ? undefined : 'tab' }
|
||||
selected = { tab.name === selectedTab }
|
||||
text = { label } />
|
||||
);
|
||||
|
@ -290,25 +352,45 @@ const DialogWithTabs = ({
|
|||
</div>
|
||||
)}
|
||||
{(!isMobile || selectedTab) && (
|
||||
<div className = { classes.contentContainer }>
|
||||
<div className = { cx(classes.buttonContainer, classes.closeButtonContainer) }>
|
||||
{isMobile && (
|
||||
<div
|
||||
className = { classes.contentContainer }
|
||||
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 }>
|
||||
<h2
|
||||
className = { classes.title }
|
||||
tabIndex = { -1 }>
|
||||
{(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)}
|
||||
</h2>
|
||||
<ClickableIcon
|
||||
accessibilityLabel = { t('dialog.Back') }
|
||||
icon = { IconArrowBack }
|
||||
id = 'modal-header-back-button'
|
||||
onClick = { back } />
|
||||
<h2 className = { classes.title }>
|
||||
{(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)}
|
||||
</h2>
|
||||
</span>
|
||||
)}
|
||||
{closeIcon}
|
||||
</div>
|
||||
<div className = { classes.content }>
|
||||
{selectedTabComponent}
|
||||
</div>
|
||||
{closeIcon}
|
||||
</div>
|
||||
)}
|
||||
{tabs.map(tab => (
|
||||
<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
|
||||
className = { cx(classes.buttonContainer, classes.footer) }>
|
||||
<Button
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { makeStyles } from 'tss-react/mui';
|
||||
|
||||
import { isMobileBrowser } from '../../../environment/utils';
|
||||
|
@ -11,6 +11,7 @@ interface ITabProps {
|
|||
selected: string;
|
||||
tabs: Array<{
|
||||
accessibilityLabel: string;
|
||||
controlsId: string;
|
||||
countBadge?: number;
|
||||
disabled?: boolean;
|
||||
id: string;
|
||||
|
@ -87,26 +88,52 @@ const Tabs = ({
|
|||
}: ITabProps) => {
|
||||
const { classes, cx } = useStyles();
|
||||
const isMobile = isMobileBrowser();
|
||||
|
||||
const handleChange = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
onChange(e.currentTarget.id);
|
||||
const onClick = useCallback(id => () => {
|
||||
onChange(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 (
|
||||
<div
|
||||
aria-label = { accessibilityLabel }
|
||||
className = { cx(classes.container, className) }
|
||||
role = 'tablist'>
|
||||
{tabs.map(tab => (
|
||||
{tabs.map((tab, index) => (
|
||||
<button
|
||||
aria-controls = { tab.controlsId }
|
||||
aria-label = { tab.accessibilityLabel }
|
||||
aria-selected = { selected === tab.id }
|
||||
className = { cx(classes.tab, selected === tab.id && 'selected', isMobile && 'is-mobile') }
|
||||
disabled = { tab.disabled }
|
||||
id = { tab.id }
|
||||
key = { tab.id }
|
||||
onClick = { handleChange }
|
||||
role = 'tab'>
|
||||
onClick = { onClick(tab.id) }
|
||||
onKeyDown = { onKeyDown(index) }
|
||||
role = 'tab'
|
||||
tabIndex = { selected === tab.id ? undefined : -1 }>
|
||||
{tab.label}
|
||||
{tab.countBadge && <span className = { classes.badge }>{tab.countBadge}</span>}
|
||||
</button>
|
||||
|
|
|
@ -202,7 +202,8 @@ class CalendarList extends AbstractPage<Props> {
|
|||
className = 'meetings-list-empty-button'
|
||||
onClick = { this._onOpenSettings }
|
||||
onKeyPress = { this._onKeyPressOpenSettings }
|
||||
role = 'button'>
|
||||
role = 'button'
|
||||
tabIndex = { 0 }>
|
||||
<Icon
|
||||
className = 'meetings-list-empty-icon'
|
||||
src = { IconCalendar } />
|
||||
|
|
|
@ -134,35 +134,38 @@ class Chat extends AbstractChat<Props> {
|
|||
_renderChat() {
|
||||
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 (
|
||||
<>
|
||||
{ _isPollsEnabled && this._renderTabs() }
|
||||
<div
|
||||
aria-labelledby = { CHAT_TABS.CHAT }
|
||||
className = { clsx('chat-panel', !_isPollsEnabled && 'chat-panel-no-tabs') }
|
||||
id = 'chat-panel'
|
||||
role = 'tabpanel'>
|
||||
className = { clsx(
|
||||
'chat-panel',
|
||||
!_isPollsEnabled && 'chat-panel-no-tabs',
|
||||
_isPollsTabFocused && 'hide'
|
||||
) }
|
||||
id = { `${CHAT_TABS.CHAT}-panel` }
|
||||
role = 'tabpanel'
|
||||
tabIndex = { 0 }>
|
||||
<MessageContainer
|
||||
messages = { this.props._messages } />
|
||||
<MessageRecipient />
|
||||
<ChatInput
|
||||
onSend = { this._onSendMessage } />
|
||||
</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'),
|
||||
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')
|
||||
}
|
||||
] } />
|
||||
|
|
|
@ -115,7 +115,6 @@ class ChatInput extends Component<IProps, IState> {
|
|||
</div>
|
||||
)}
|
||||
<Input
|
||||
autoFocus = { true }
|
||||
className = 'chat-input'
|
||||
icon = { this.props._areSmileysDisabled ? undefined : IconFaceSmile }
|
||||
iconClick = { this._toggleSmileysPanel }
|
||||
|
|
|
@ -191,7 +191,7 @@ class DesktopPicker extends PureComponent<IProps, IState> {
|
|||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
const { selectedTab, selectedSource, sources } = this.state;
|
||||
const { selectedTab, selectedSource, sources, types } = this.state;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
@ -204,14 +204,27 @@ class DesktopPicker extends PureComponent<IProps, IState> {
|
|||
size = 'large'
|
||||
titleKey = 'dialog.shareYourScreen'>
|
||||
{ this._renderTabs() }
|
||||
<DesktopPickerPane
|
||||
key = { selectedTab }
|
||||
onClick = { this._onPreviewClick }
|
||||
onDoubleClick = { this._onSubmit }
|
||||
onShareAudioChecked = { this._onShareAudioChecked }
|
||||
selectedSourceId = { selectedSource.id }
|
||||
sources = { sources[selectedTab as keyof typeof sources] }
|
||||
type = { selectedTab } />
|
||||
{types.map(type => (
|
||||
<div
|
||||
aria-labelledby = { `${type}-button` }
|
||||
className = { selectedTab === type ? undefined : 'hide' }
|
||||
id = { `${type}-panel` }
|
||||
key = { type }
|
||||
role = 'tabpanel'
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
@ -348,17 +361,18 @@ class DesktopPicker extends PureComponent<IProps, IState> {
|
|||
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 (
|
||||
<Tabs
|
||||
accessibilityLabel = ''
|
||||
accessibilityLabel = { t('dialog.sharingTabs') }
|
||||
className = 'desktop-picker-tabs-container'
|
||||
onChange = { this._onTabSelected }
|
||||
selected = { this.state.selectedTab }
|
||||
selected = { `${this.state.selectedTab}-tab` }
|
||||
tabs = { tabs } />);
|
||||
}
|
||||
|
||||
|
|
|
@ -40,7 +40,6 @@ const PollsPane = ({ createMode, onCreate, setCreateMode, t }: AbstractProps) =>
|
|||
<div className = { classes.footer }>
|
||||
<Button
|
||||
accessibilityLabel = { t('polls.create.create') }
|
||||
autoFocus = { true }
|
||||
fullWidth = { true }
|
||||
labelKey = { 'polls.create.create' }
|
||||
onClick = { onCreate } />
|
||||
|
|
|
@ -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>);
|
||||
}
|
||||
}
|
|
@ -1,7 +1,5 @@
|
|||
// @flow
|
||||
import React, { Component } from 'react';
|
||||
|
||||
import Tab from './Tab';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
/**
|
||||
* The type of the React {@code Component} props of {@link Tabs}.
|
||||
|
@ -9,14 +7,10 @@ import Tab from './Tab';
|
|||
type Props = {
|
||||
|
||||
/**
|
||||
* Handler for selecting the tab.
|
||||
* Accessibility label for the tabs container.
|
||||
*
|
||||
*/
|
||||
onSelect: Function,
|
||||
|
||||
/**
|
||||
* The index of the selected tab.
|
||||
*/
|
||||
selected: number,
|
||||
accessibilityLabel: string,
|
||||
|
||||
/**
|
||||
* Tabs information.
|
||||
|
@ -27,44 +21,87 @@ type Props = {
|
|||
/**
|
||||
* A React component that implements tabs.
|
||||
*
|
||||
* @returns {ReactElement} The component.
|
||||
*/
|
||||
export default class Tabs extends Component<Props> {
|
||||
static defaultProps = {
|
||||
tabs: [],
|
||||
selected: 0
|
||||
};
|
||||
const Tabs = ({ accessibilityLabel, tabs }: Props) => {
|
||||
const [ current, setCurrent ] = useState(0);
|
||||
|
||||
/**
|
||||
* Implements the React Components's render method.
|
||||
*
|
||||
* @inheritdoc
|
||||
*/
|
||||
render() {
|
||||
const { onSelect, selected, tabs } = this.props;
|
||||
const { content = null } = tabs.length
|
||||
? tabs[Math.min(selected, tabs.length - 1)]
|
||||
: {};
|
||||
const onClick = useCallback(index => event => {
|
||||
event.preventDefault();
|
||||
setCurrent(index);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className = 'tab-container'>
|
||||
{ tabs.length > 1 ? (
|
||||
<div className = 'tab-buttons'>
|
||||
{
|
||||
tabs.map((tab, index) => (
|
||||
<Tab
|
||||
index = { index }
|
||||
isSelected = { index === selected }
|
||||
key = { index }
|
||||
label = { tab.label }
|
||||
onSelect = { onSelect } />
|
||||
))
|
||||
}
|
||||
</div>) : null
|
||||
}
|
||||
<div className = 'tab-content'>
|
||||
{ content }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
const onKeyDown = useCallback(index => event => {
|
||||
let newIndex = 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) {
|
||||
setCurrent(newIndex);
|
||||
}
|
||||
}, [ 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(`#${`${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;
|
||||
|
|
|
@ -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: <CalendarList />
|
||||
});
|
||||
|
@ -412,6 +399,7 @@ class WelcomePage extends AbstractWelcomePage {
|
|||
|
||||
if (_recentListEnabled) {
|
||||
tabs.push({
|
||||
id: 'recent',
|
||||
label: t('welcomepage.recentMeetings'),
|
||||
content: <RecentList />
|
||||
});
|
||||
|
@ -423,9 +411,9 @@ class WelcomePage extends AbstractWelcomePage {
|
|||
|
||||
return (
|
||||
<Tabs
|
||||
onSelect = { this._onTabSelected }
|
||||
selected = { this.state.selectedTab }
|
||||
tabs = { tabs } />);
|
||||
accessibilityLabel = { t('welcomepage.meetingsAccessibilityLabel') }
|
||||
tabs = { tabs } />
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
Loading…
Reference in New Issue