feat(ui-components) Add Dialog Component (#12260)

This commit is contained in:
Robert Pintilii 2022-09-29 13:26:34 +03:00 committed by GitHub
parent 0d917df1fb
commit bfa88f13dc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 481 additions and 71 deletions

View File

@ -3,9 +3,9 @@
import { AtlasKitThemeProvider } from '@atlaskit/theme'; import { AtlasKitThemeProvider } from '@atlaskit/theme';
import React from 'react'; import React from 'react';
import { DialogContainer } from '../../base/dialog';
import GlobalStyles from '../../base/ui/components/GlobalStyles'; import GlobalStyles from '../../base/ui/components/GlobalStyles';
import JitsiThemeProvider from '../../base/ui/components/JitsiThemeProvider.web'; import JitsiThemeProvider from '../../base/ui/components/JitsiThemeProvider.web';
import DialogContainer from '../../base/ui/components/web/DialogContainer';
import { ChromeExtensionBanner } from '../../chrome-extension-banner'; import { ChromeExtensionBanner } from '../../chrome-extension-banner';
import { AbstractApp } from './AbstractApp'; import { AbstractApp } from './AbstractApp';

View File

@ -1,6 +1,6 @@
// @flow import { ComponentType } from 'react';
import type { Dispatch } from 'redux'; import { IStore } from '../../app/types';
import { import {
HIDE_DIALOG, HIDE_DIALOG,
@ -22,7 +22,7 @@ import { isDialogOpen } from './functions';
* component: (React.Component | undefined) * component: (React.Component | undefined)
* }} * }}
*/ */
export function hideDialog(component: ?Object) { export function hideDialog(component?: ComponentType) {
return { return {
type: HIDE_DIALOG, type: HIDE_DIALOG,
component component
@ -54,7 +54,7 @@ export function hideSheet() {
* componentProps: (Object | undefined) * componentProps: (Object | undefined)
* }} * }}
*/ */
export function openDialog(component: Object, componentProps: ?Object) { export function openDialog(component: ComponentType, componentProps?: Object) {
return { return {
type: OPEN_DIALOG, type: OPEN_DIALOG,
component, component,
@ -74,7 +74,7 @@ export function openDialog(component: Object, componentProps: ?Object) {
* componentProps: (Object | undefined) * componentProps: (Object | undefined)
* }} * }}
*/ */
export function openSheet(component: Object, componentProps: ?Object) { export function openSheet(component: ComponentType, componentProps?: Object) {
return { return {
type: OPEN_SHEET, type: OPEN_SHEET,
component, component,
@ -92,8 +92,8 @@ export function openSheet(component: Object, componentProps: ?Object) {
* specified {@code component}. * specified {@code component}.
* @returns {Function} * @returns {Function}
*/ */
export function toggleDialog(component: Object, componentProps: ?Object) { export function toggleDialog(component: ComponentType, componentProps?: Object) {
return (dispatch: Dispatch<any>, getState: Function) => { return (dispatch: IStore['dispatch'], getState: IStore['getState']) => {
if (isDialogOpen(getState, component)) { if (isDialogOpen(getState, component)) {
dispatch(hideDialog(component)); dispatch(hideDialog(component));
} else { } else {

View File

@ -1,34 +1,33 @@
/* @flow */ import React, { Component, ComponentType } from 'react';
import React, { Component } from 'react'; import { IState } from '../../../app/types';
import { ReactionEmojiProps } from '../../../reactions/constants';
import { type ReactionEmojiProps } from '../../../reactions/constants';
/** /**
* The type of the React {@code Component} props of {@link DialogContainer}. * The type of the React {@code Component} props of {@link DialogContainer}.
*/ */
type Props = { interface Props {
/** /**
* The component to render. * The component to render.
*/ */
_component: Function, _component: ComponentType;
/** /**
* The props to pass to the component that will be rendered. * The props to pass to the component that will be rendered.
*/ */
_componentProps: Object, _componentProps: Object;
/**
* True if the UI is in a compact state where we don't show dialogs.
*/
_reducedUI: boolean,
/** /**
* Array of reactions to be displayed. * Array of reactions to be displayed.
*/ */
_reactionsQueue: Array<ReactionEmojiProps> _reactionsQueue: Array<ReactionEmojiProps>;
};
/**
* True if the UI is in a compact state where we don't show dialogs.
*/
_reducedUI: boolean;
}
/** /**
* Implements a DialogContainer responsible for showing all dialogs. * Implements a DialogContainer responsible for showing all dialogs.
@ -61,7 +60,7 @@ export default class AbstractDialogContainer extends Component<Props> {
* @private * @private
* @returns {Props} * @returns {Props}
*/ */
export function abstractMapStateToProps(state: Object): $Shape<Props> { export function abstractMapStateToProps(state: IState) {
const stateFeaturesBaseDialog = state['features/base/dialog']; const stateFeaturesBaseDialog = state['features/base/dialog'];
const { reducedUI } = state['features/base/responsive-ui']; const { reducedUI } = state['features/base/responsive-ui'];

View File

@ -1,31 +0,0 @@
import { ModalTransition } from '@atlaskit/modal-dialog';
import React from 'react';
import { connect } from '../../../redux';
import AbstractDialogContainer, {
abstractMapStateToProps
} from '../AbstractDialogContainer';
/**
* Implements a DialogContainer responsible for showing all dialogs. Necessary
* for supporting @atlaskit's modal animations.
*
* @augments AbstractDialogContainer
*/
class DialogContainer extends AbstractDialogContainer {
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return (
<ModalTransition>
{ this._renderDialogContent() }
</ModalTransition>
);
}
}
export default connect(abstractMapStateToProps)(DialogContainer);

View File

@ -2,7 +2,7 @@
export { default as AbstractDialogTab } from './AbstractDialogTab'; export { default as AbstractDialogTab } from './AbstractDialogTab';
export type { Props as AbstractDialogTabProps } from './AbstractDialogTab'; export type { Props as AbstractDialogTabProps } from './AbstractDialogTab';
export { default as Dialog } from './Dialog'; export { default as Dialog } from './Dialog-old';
export { default as DialogContainer } from './DialogContainer';
export { default as DialogWithTabs } from './DialogWithTabs'; export { default as DialogWithTabs } from './DialogWithTabs';
export { default as StatelessDialog } from './StatelessDialog'; export { default as StatelessDialog } from './StatelessDialog';
export { default as DialogContainer } from '../../../ui/components/web/DialogContainer';

View File

@ -1,16 +1,20 @@
/* @flow */ import { ComponentType } from 'react';
import { IState } from '../../app/types';
import { IStateful } from '../app/types';
// eslint-disable-next-line lines-around-comment
// @ts-ignore
import { ColorSchemeRegistry } from '../color-scheme'; import { ColorSchemeRegistry } from '../color-scheme';
import { toState } from '../redux'; import { toState } from '../redux/functions';
/** /**
* Checks if any {@code Dialog} is currently open. * Checks if any {@code Dialog} is currently open.
* *
* @param {Function|Object} stateful - The redux store, the redux * @param {IStateful} stateful - The redux store, the redux
* {@code getState} function, or the redux state itself. * {@code getState} function, or the redux state itself.
* @returns {boolean} * @returns {boolean}
*/ */
export function isAnyDialogOpen(stateful: Function) { export function isAnyDialogOpen(stateful: IStateful) {
return Boolean(toState(stateful)['features/base/dialog'].component); return Boolean(toState(stateful)['features/base/dialog'].component);
} }
@ -18,25 +22,25 @@ export function isAnyDialogOpen(stateful: Function) {
* Checks if a {@code Dialog} with a specific {@code component} is currently * Checks if a {@code Dialog} with a specific {@code component} is currently
* open. * open.
* *
* @param {Function|Object} stateful - The redux store, the redux * @param {IStateful} stateful - The redux store, the redux
* {@code getState} function, or the redux state itself. * {@code getState} function, or the redux state itself.
* @param {React.Component} component - The {@code component} of a * @param {React.Component} component - The {@code component} of a
* {@code Dialog} to be checked. * {@code Dialog} to be checked.
* @returns {boolean} * @returns {boolean}
*/ */
export function isDialogOpen(stateful: Function | Object, component: Object) { export function isDialogOpen(stateful: IStateful, component: ComponentType) {
return toState(stateful)['features/base/dialog'].component === component; return toState(stateful)['features/base/dialog'].component === component;
} }
/** /**
* Maps part of the Redux state to the props of any Dialog based component. * Maps part of the Redux state to the props of any Dialog based component.
* *
* @param {Object} state - The Redux state. * @param {IState} state - The Redux state.
* @returns {{ * @returns {{
* _dialogStyles: StyleType * _dialogStyles: StyleType
* }} * }}
*/ */
export function _abstractMapStateToProps(state: Object): Object { export function _abstractMapStateToProps(state: IState) {
return { return {
_dialogStyles: ColorSchemeRegistry.get(state, 'Dialog') _dialogStyles: ColorSchemeRegistry.get(state, 'Dialog')
}; };

View File

@ -24,6 +24,11 @@ const useStyles = makeStyles()((theme: Theme) => {
backgroundColor: theme.palette.ui02 backgroundColor: theme.palette.ui02
}, },
'&:focus': {
outline: 0,
boxShadow: `0px 0px 0px 2px ${theme.palette.focus01}`
},
'&:active': { '&:active': {
backgroundColor: theme.palette.ui03 backgroundColor: theme.palette.ui03
}, },

View File

@ -0,0 +1,250 @@
import { Theme } from '@mui/material';
import React, { ReactElement, useCallback, useContext, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { keyframes } from 'tss-react';
import { makeStyles } from 'tss-react/mui';
import { IconClose } from '../../../icons/svg';
import { withPixelLineHeight } from '../../../styles/functions.web';
import Button from './Button';
import ClickableIcon from './ClickableIcon';
import { DialogTransitionContext } from './DialogTransition';
const useStyles = makeStyles()((theme: Theme) => {
return {
container: {
width: '100%',
height: '100%',
position: 'fixed',
top: 0,
left: 0,
display: 'flex',
justifyContent: 'center',
alignItems: 'flex-start',
animation: `${keyframes`
0% {
opacity: 0.4;
}
100% {
opacity: 1;
}
`} 0.2s forwards ease-out`,
'&.unmount': {
animation: `${keyframes`
0% {
opacity: 1;
}
100% {
opacity: 0.5;
}
`} 0.15s forwards ease-in`
}
},
backdrop: {
position: 'absolute',
width: '100%',
height: '100%',
top: 0,
left: 0,
backgroundColor: theme.palette.ui02,
opacity: 0.75
},
modal: {
zIndex: 1,
backgroundColor: theme.palette.ui01,
border: `1px solid ${theme.palette.ui03}`,
boxShadow: '0px 4px 25px 4px rgba(20, 20, 20, 0.6)',
borderRadius: `${theme.shape.borderRadius}px`,
display: 'flex',
flexDirection: 'column',
height: 'auto',
minHeight: '200px',
maxHeight: '560px',
marginTop: '64px',
animation: `${keyframes`
0% {
margin-top: 85px
}
100% {
margin-top: 64px
}
`} 0.2s forwards ease-out`,
'&.medium': {
width: '400px'
},
'&.large': {
width: '664px'
},
'&.unmount': {
animation: `${keyframes`
0% {
margin-top: 64px
}
100% {
margin-top: 40px
}
`} 0.15s forwards ease-in`
},
'@media (max-width: 448px)': {
width: '100% !important',
maxHeight: 'initial',
height: '100%',
margin: 0,
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
animation: `${keyframes`
0% {
margin-top: 15px
}
100% {
margin-top: 0
}
`} 0.2s forwards ease-out`,
'&.unmount': {
animation: `${keyframes`
0% {
margin-top: 0
}
100% {
margin-top: 15px
}
`} 0.15s forwards ease-in`
}
}
},
header: {
width: '100%',
padding: '24px',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'space-between'
},
title: {
color: theme.palette.text01,
...withPixelLineHeight(theme.typography.heading5),
margin: 0,
padding: 0
},
content: {
height: 'auto',
overflowY: 'auto',
width: '100%',
boxSizing: 'border-box',
padding: '0 24px',
overflowX: 'hidden',
'@media (max-width: 448px)': {
height: '100%'
}
},
footer: {
width: '100%',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'flex-end',
padding: '24px',
'& button:last-child': {
marginLeft: '16px'
}
}
};
});
interface DialogProps {
cancelKey?: string;
children?: ReactElement | ReactElement[];
description?: string;
ok?: {
disabled?: boolean;
key: string;
};
onCancel: () => void;
onSubmit?: () => void;
size?: 'large' | 'medium';
title?: string;
titleKey?: string;
}
const Dialog = ({
title,
titleKey,
description,
size = 'medium',
onCancel,
children,
ok,
cancelKey,
onSubmit
}: DialogProps) => {
const { classes, cx } = useStyles();
const { t } = useTranslation();
const { isUnmounting } = useContext(DialogTransitionContext);
const handleKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
onCancel();
}
}, []);
useEffect(() => {
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<div className = { cx(classes.container, isUnmounting && 'unmount') }>
<div
className = { classes.backdrop }
onClick = { onCancel } />
<div
aria-describedby = { description }
aria-labelledby = { title ?? t(titleKey ?? '') }
aria-modal = { true }
className = { cx(classes.modal, isUnmounting && 'unmount', size) }
role = 'dialog'>
<div className = { classes.header }>
<p className = { classes.title }>{title ?? t(titleKey ?? '')}</p>
<ClickableIcon
accessibilityLabel = { t('dialog.close') }
icon = { IconClose }
onClick = { onCancel } />
</div>
<div className = { classes.content }>{children}</div>
<div className = { classes.footer }>
{cancelKey && <Button
accessibilityLabel = { t(cancelKey) }
labelKey = { cancelKey }
onClick = { onCancel }
type = 'tertiary' />}
{ok && <Button
accessibilityLabel = { t(ok.key) }
disabled = { ok.disabled }
labelKey = { ok.key }
onClick = { onSubmit } />}
</div>
</div>
</div>
);
};
export default Dialog;

View File

@ -0,0 +1,138 @@
import { ModalTransition } from '@atlaskit/modal-dialog';
import React, { Component, ComponentType } from 'react';
import { IState } from '../../../../app/types';
import KeyboardShortcutsDialog from '../../../../keyboard-shortcuts/components/web/KeyboardShortcutsDialog';
import { ReactionEmojiProps } from '../../../../reactions/constants';
import { connect } from '../../../redux/functions';
import DialogTransition from './DialogTransition';
interface Props {
/**
* The component to render.
*/
_component: ComponentType;
/**
* The props to pass to the component that will be rendered.
*/
_componentProps: Object;
/**
* Array of reactions to be displayed.
*/
_reactionsQueue: Array<ReactionEmojiProps>;
/**
* True if the UI is in a compact state where we don't show dialogs.
*/
_reducedUI: boolean;
}
// This function is necessary while the transition from @atlaskit dialog to our component is ongoing.
const isNewDialog = (component: any) => {
const list = [ KeyboardShortcutsDialog ];
return Boolean(list.find(comp => comp === component));
};
// Needed for the transition to our component.
type State = {
isNewDialog: boolean;
};
/**
* Implements a DialogContainer responsible for showing all dialogs. Necessary
* for supporting @atlaskit's modal animations.
*
*/
class DialogContainer extends Component<Props, State> {
/**
* Initializes a new {@code DialogContainer} instance.
*
* @param {Props} props - The React {@code Component} props to initialize
* the new {@code DialogContainer} instance with.
*/
constructor(props: Props) {
super(props);
this.state = {
isNewDialog: false
};
}
/**
* Check which Dialog container to render.
* Needed during transition from atlaskit.
*
* @inheritdoc
* @returns {void}
*/
componentDidUpdate(prevProps: Props) {
if (this.props._component && prevProps._component !== this.props._component) {
// eslint-disable-next-line react/no-did-update-set-state
this.setState({
isNewDialog: isNewDialog(this.props._component)
});
}
}
/**
* Returns the dialog to be displayed.
*
* @private
* @returns {ReactElement|null}
*/
_renderDialogContent() {
const {
_component: component,
_reducedUI: reducedUI
} = this.props;
return (
component && !reducedUI
? React.createElement(component, this.props._componentProps)
: null);
}
/**
* Implements React's {@link Component#render()}.
*
* @inheritdoc
* @returns {ReactElement}
*/
render() {
return this.state.isNewDialog ? (
<DialogTransition>
{this._renderDialogContent()}
</DialogTransition>
) : (
<ModalTransition>
{ this._renderDialogContent() }
</ModalTransition>
);
}
}
/**
* Maps (parts of) the redux state to the associated
* {@code AbstractDialogContainer}'s props.
*
* @param {Object} state - The redux state.
* @private
* @returns {Props}
*/
function mapStateToProps(state: IState) {
const stateFeaturesBaseDialog = state['features/base/dialog'];
const { reducedUI } = state['features/base/responsive-ui'];
return {
_component: stateFeaturesBaseDialog.component,
_componentProps: stateFeaturesBaseDialog.componentProps,
_reducedUI: reducedUI
};
}
export default connect(mapStateToProps)(DialogContainer);

View File

@ -0,0 +1,28 @@
import React, { ReactElement, useEffect, useState } from 'react';
export const DialogTransitionContext = React.createContext({ isUnmounting: false });
const DialogTransition = ({ children }: { children: ReactElement | null; }) => {
const [ childrenToRender, setChildrenToRender ] = useState(children);
const [ isUnmounting, setIsUnmounting ] = useState(false);
useEffect(() => {
if (children === null) {
setIsUnmounting(true);
setTimeout(() => {
setChildrenToRender(children);
setIsUnmounting(false);
}, 150);
} else {
setChildrenToRender(children);
}
}, [ children ]);
return (
<DialogTransitionContext.Provider value = {{ isUnmounting }}>
{childrenToRender}
</DialogTransitionContext.Provider>
);
};
export default DialogTransition;

View File

@ -3,10 +3,12 @@ import { withStyles } from '@mui/styles';
import clsx from 'clsx'; import clsx from 'clsx';
import React, { Component } from 'react'; import React, { Component } from 'react';
import { WithTranslation } from 'react-i18next'; import { WithTranslation } from 'react-i18next';
import { connect } from 'react-redux';
// @ts-ignore import { IStore } from '../../../app/types';
import { Dialog } from '../../../base/dialog'; import { hideDialog } from '../../../base/dialog/actions';
import { translate } from '../../../base/i18n/functions'; import { translate } from '../../../base/i18n/functions';
import Dialog from '../../../base/ui/components/web/Dialog';
/** /**
* The type of the React {@code Component} props of * The type of the React {@code Component} props of
@ -14,6 +16,11 @@ import { translate } from '../../../base/i18n/functions';
*/ */
interface Props extends WithTranslation { interface Props extends WithTranslation {
/**
* Dispatches close dialog.
*/
_onCloseDialog: () => void;
/** /**
* An object containing the CSS classes. * An object containing the CSS classes.
*/ */
@ -73,10 +80,8 @@ class KeyboardShortcutsDialog extends Component<Props> {
return ( return (
<Dialog <Dialog
cancelKey = { 'dialog.close' } onCancel = { this.props._onCloseDialog }
submitDisabled = { true } titleKey = 'keyboardShortcuts.keyboardShortcuts'>
titleKey = 'keyboardShortcuts.keyboardShortcuts'
width = 'small'>
<div <div
id = 'keyboard-shortcuts'> id = 'keyboard-shortcuts'>
<ul <ul
@ -125,4 +130,16 @@ class KeyboardShortcutsDialog extends Component<Props> {
} }
} }
export default translate(withStyles(styles)(KeyboardShortcutsDialog)); /**
* Function that maps parts of Redux actions into component props.
*
* @param {Object} dispatch - Redux dispatch.
* @returns {Object}
*/
function mapDispatchToProps(dispatch: IStore['dispatch']) {
return {
_onCloseDialog: () => dispatch(hideDialog())
};
}
export default connect(null, mapDispatchToProps)(translate(withStyles(styles)(KeyboardShortcutsDialog)));