Files
jitsi-meet/react/features/base/ui/components/web/DialogWithTabs.tsx
Hristo Terezov 1556f1b81a ref(responsive-ui): rename clientWidth to videoSpaceWidth.
Currently the clientWidth is not representing the window width but it is representing the available video space width since we are subtracting the width of the participants pane and chat area.
2025-05-06 09:40:54 -05:00

420 lines
14 KiB
TypeScript

import React, { ComponentType, useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { makeStyles } from 'tss-react/mui';
import { IReduxState } from '../../../../app/types';
import { hideDialog } from '../../../dialog/actions';
import { IconArrowBack, IconCloseLarge } from '../../../icons/svg';
import { withPixelLineHeight } from '../../../styles/functions.web';
import BaseDialog, { IProps as IBaseProps } from './BaseDialog';
import Button from './Button';
import ClickableIcon from './ClickableIcon';
import ContextMenuItem from './ContextMenuItem';
const MOBILE_BREAKPOINT = 607;
const useStyles = makeStyles()(theme => {
return {
dialog: {
flexDirection: 'row',
height: '560px',
'@media (min-width: 608px) and (max-width: 712px)': {
width: '560px'
},
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
width: '100%',
position: 'absolute',
top: 0,
left: 0,
bottom: 0
},
'@media (max-width: 448px)': {
height: '100%'
}
},
sidebar: {
display: 'flex',
flexDirection: 'column',
minWidth: '211px',
maxWidth: '100%',
borderRight: `1px solid ${theme.palette.ui03}`,
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
width: '100%',
borderRight: 'none'
}
},
menuItemMobile: {
paddingLeft: '24px'
},
titleContainer: {
margin: 0,
padding: '24px',
paddingRight: 0,
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
padding: '16px 24px'
}
},
title: {
...withPixelLineHeight(theme.typography.heading5),
color: `${theme.palette.text01} !important`,
margin: 0,
padding: 0
},
contentContainer: {
position: 'relative',
display: 'flex',
padding: '24px',
flexDirection: 'column',
overflow: 'hidden',
width: '100%',
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
padding: '0'
}
},
buttonContainer: {
width: '100%',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
flexGrow: 0,
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
justifyContent: 'space-between',
padding: '16px 24px'
}
},
backContainer: {
display: 'flex',
flexDirection: 'row-reverse',
alignItems: 'center',
'& > button': {
marginRight: '24px'
}
},
content: {
flexGrow: 1,
overflowY: 'auto',
width: '100%',
boxSizing: 'border-box',
[`@media (max-width: ${MOBILE_BREAKPOINT}px)`]: {
padding: '0 24px'
}
},
header: {
order: -1,
paddingBottom: theme.spacing(4)
},
footer: {
justifyContent: 'flex-end',
paddingTop: theme.spacing(4),
'& button:last-child': {
marginLeft: '16px'
}
}
};
});
interface IObject {
[key: string]: unknown;
}
export interface IDialogTab<P> {
cancel?: Function;
className?: string;
component: ComponentType<any>;
icon: Function;
labelKey: string;
name: string;
props?: IObject;
propsUpdateFunction?: (tabState: IObject, newProps: P, tabStates?: (IObject | undefined)[]) => P;
submit?: Function;
}
interface IProps extends IBaseProps {
defaultTab?: string;
tabs: IDialogTab<any>[];
}
const DialogWithTabs = ({
className,
defaultTab,
titleKey,
tabs
}: IProps) => {
const { classes, cx } = useStyles();
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 videoSpaceWidth = useSelector((state: IReduxState) => state['features/base/responsive-ui'].videoSpaceWidth);
const [ isMobile, setIsMobile ] = useState(false);
useEffect(() => {
if (videoSpaceWidth <= MOBILE_BREAKPOINT) {
!isMobile && setIsMobile(true);
} else {
isMobile && setIsMobile(false);
}
}, [ videoSpaceWidth, isMobile ]);
useEffect(() => {
if (isMobile) {
setSelectedTab(defaultTab);
} else {
setSelectedTab(defaultTab ?? tabs[0].name);
}
}, [ isMobile ]);
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((isCancel = true) => {
if (isCancel) {
tabs.forEach(({ cancel }) => {
cancel && dispatch(cancel());
});
}
dispatch(hideDialog());
}, []);
const onClick = useCallback((tabName: string) => () => {
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];
if (tabConfiguration.propsUpdateFunction) {
return tabConfiguration.propsUpdateFunction(
currentTabState ?? {},
tabConfiguration.props ?? {},
tabStates);
}
return { ...currentTabState };
};
const onTabStateChange = useCallback((tabId: number, state: IObject) => {
const newTabStates = [ ...tabStates ];
newTabStates[tabId] = state;
setTabStates(newTabStates);
}, [ tabStates ]);
const onSubmit = useCallback(() => {
tabs.forEach(({ submit }, idx) => {
submit?.(tabStates[idx]);
});
onClose(false);
}, [ tabs, tabStates ]);
const selectedTabIndex = useMemo(() => {
if (selectedTab) {
return tabs.findIndex(tab => tab.name === selectedTab);
}
return null;
}, [ selectedTab ]);
const selectedTabComponent = useMemo(() => {
if (selectedTabIndex !== null) {
const TabComponent = tabs[selectedTabIndex].component;
return (
<div
className = { tabs[selectedTabIndex].className }
key = { tabs[selectedTabIndex].name }>
<TabComponent
onTabStateChange = { onTabStateChange }
tabId = { selectedTabIndex }
{ ...getTabProps(selectedTabIndex) } />
</div>
);
}
return null;
}, [ selectedTabIndex, tabStates ]);
const closeIcon = useMemo(() => (
<ClickableIcon
accessibilityLabel = { t('dialog.accessibilityLabel.close') }
icon = { IconCloseLarge }
id = 'modal-header-close-button'
onClick = { onClose } />
), [ onClose ]);
return (
<BaseDialog
className = { cx(classes.dialog, className) }
onClose = { onClose }
size = 'large'
titleKey = { titleKey }>
{(!isMobile || !selectedTab) && (
<div
aria-orientation = 'vertical'
className = { classes.sidebar }
role = { isMobile ? undefined : 'tablist' }>
<div className = { classes.titleContainer }>
<h1
className = { classes.title }
tabIndex = { -1 }>
{t(titleKey ?? '')}
</h1>
{isMobile && closeIcon}
</div>
{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 } />
);
})}
</div>
)}
{(!isMobile || selectedTab) && (
<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 }>
<h1
className = { classes.title }
tabIndex = { -1 }>
{(selectedTabIndex !== null) && t(tabs[selectedTabIndex].labelKey)}
</h1>
<ClickableIcon
accessibilityLabel = { t('dialog.Back') }
icon = { IconArrowBack }
id = 'modal-header-back-button'
onClick = { back } />
</span>
{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
accessibilityLabel = { t('dialog.accessibilityLabel.Cancel') }
id = 'modal-dialog-cancel-button'
labelKey = { 'dialog.Cancel' }
onClick = { onClose }
type = 'tertiary' />
<Button
accessibilityLabel = { t('dialog.accessibilityLabel.Ok') }
id = 'modal-dialog-ok-button'
labelKey = { 'dialog.Ok' }
onClick = { onSubmit } />
</div>
</div>
)}
</BaseDialog>
);
};
export default DialogWithTabs;