Improve dialog logic, add "asDialog" helper

This commit is contained in:
DaneEveritt 2022-07-03 13:29:23 -04:00
parent 822949408f
commit a4feed24a8
No known key found for this signature in database
GPG key ID: EEA66103B3D71F53
10 changed files with 131 additions and 77 deletions

View file

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import { DialogProps } from '@/components/elements/dialog/Dialog'; import { Dialog, DialogProps } from '@/components/elements/dialog';
import { Dialog } from '@/components/elements/dialog';
import { Button } from '@/components/elements/button/index'; import { Button } from '@/components/elements/button/index';
import CopyOnClick from '@/components/elements/CopyOnClick'; import CopyOnClick from '@/components/elements/CopyOnClick';
import { Alert } from '@/components/elements/alert'; import { Alert } from '@/components/elements/alert';

View file

@ -1,6 +1,5 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { Dialog } from '@/components/elements/dialog'; import { Dialog, DialogProps } from '@/components/elements/dialog';
import { DialogProps } from '@/components/elements/dialog/Dialog';
import getTwoFactorTokenData, { TwoFactorTokenData } from '@/api/account/getTwoFactorTokenData'; import getTwoFactorTokenData, { TwoFactorTokenData } from '@/api/account/getTwoFactorTokenData';
import { useFlashKey } from '@/plugins/useFlash'; import { useFlashKey } from '@/plugins/useFlash';
import tw from 'twin.macro'; import tw from 'twin.macro';

View file

@ -1,9 +1,8 @@
import React from 'react'; import React from 'react';
import { Dialog } from '@/components/elements/dialog/index'; import { Dialog, RenderDialogProps } from './';
import { FullDialogProps } from '@/components/elements/dialog/Dialog';
import { Button } from '@/components/elements/button/index'; import { Button } from '@/components/elements/button/index';
type ConfirmationProps = Omit<FullDialogProps, 'description' | 'children'> & { type ConfirmationProps = Omit<RenderDialogProps, 'description' | 'children'> & {
children: React.ReactNode; children: React.ReactNode;
confirm?: string | undefined; confirm?: string | undefined;
onConfirmed: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void; onConfirmed: (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;

View file

@ -1,38 +1,37 @@
import React, { useEffect, useState } from 'react'; import React, { useRef, useState } from 'react';
import { Dialog as HDialog } from '@headlessui/react'; import { Dialog as HDialog } from '@headlessui/react';
import { Button } from '@/components/elements/button/index'; import { Button } from '@/components/elements/button/index';
import { XIcon } from '@heroicons/react/solid'; import { XIcon } from '@heroicons/react/solid';
import DialogIcon, { IconPosition } from '@/components/elements/dialog/DialogIcon'; import { AnimatePresence, motion } from 'framer-motion';
import { AnimatePresence, motion, useAnimation } from 'framer-motion'; import { DialogContext, IconPosition, RenderDialogProps, styles } from './';
import ConfirmationDialog from '@/components/elements/dialog/ConfirmationDialog';
import DialogContext from './context';
import DialogFooter from '@/components/elements/dialog/DialogFooter';
import styles from './style.module.css';
export interface DialogProps {
open: boolean;
onClose: () => void;
}
export interface FullDialogProps extends DialogProps {
hideCloseIcon?: boolean;
preventExternalClose?: boolean;
title?: string;
description?: string | undefined;
children?: React.ReactNode;
}
const spring = { type: 'spring', damping: 15, stiffness: 300, duration: 0.15 };
const variants = { const variants = {
open: { opacity: 1, scale: 1, transition: spring }, open: {
closed: { opacity: 0, scale: 0.85, transition: spring }, scale: 1,
opacity: 1,
transition: {
type: 'spring',
damping: 15,
stiffness: 300,
duration: 0.15,
},
},
closed: {
scale: 0.75,
opacity: 0,
transition: {
type: 'easeIn',
duration: 0.15,
},
},
bounce: { bounce: {
scale: 0.95, scale: 0.95,
opacity: 1,
transition: { type: 'linear', duration: 0.075 }, transition: { type: 'linear', duration: 0.075 },
}, },
}; };
const Dialog = ({ export default ({
open, open,
title, title,
description, description,
@ -40,28 +39,25 @@ const Dialog = ({
hideCloseIcon, hideCloseIcon,
preventExternalClose, preventExternalClose,
children, children,
}: FullDialogProps) => { }: RenderDialogProps) => {
const controls = useAnimation(); const container = useRef<HTMLDivElement>(null);
const [icon, setIcon] = useState<React.ReactNode>(); const [icon, setIcon] = useState<React.ReactNode>();
const [footer, setFooter] = useState<React.ReactNode>(); const [footer, setFooter] = useState<React.ReactNode>();
const [iconPosition, setIconPosition] = useState<IconPosition>('title'); const [iconPosition, setIconPosition] = useState<IconPosition>('title');
const [down, setDown] = useState(false);
const onContainerClick = (down: boolean, e: React.MouseEvent<HTMLDivElement>): void => {
if (e.target instanceof HTMLElement && container.current?.isSameNode(e.target)) {
setDown(down);
}
};
const onDialogClose = (): void => { const onDialogClose = (): void => {
if (!preventExternalClose) { if (!preventExternalClose) {
return onClose(); return onClose();
} }
controls
.start('bounce')
.then(() => controls.start('open'))
.catch(console.error);
}; };
useEffect(() => {
controls.start(open ? 'open' : 'closed').catch(console.error);
}, [open]);
return ( return (
<AnimatePresence> <AnimatePresence>
{open && ( {open && (
@ -78,10 +74,17 @@ const Dialog = ({
> >
<div className={'fixed inset-0 bg-gray-900/50 z-40'} /> <div className={'fixed inset-0 bg-gray-900/50 z-40'} />
<div className={'fixed inset-0 overflow-y-auto z-50'}> <div className={'fixed inset-0 overflow-y-auto z-50'}>
<div className={styles.container}> <div
ref={container}
className={styles.container}
onMouseDown={onContainerClick.bind(this, true)}
onMouseUp={onContainerClick.bind(this, false)}
>
<HDialog.Panel <HDialog.Panel
as={motion.div} as={motion.div}
animate={controls} initial={'closed'}
animate={down ? 'bounce' : 'open'}
exit={'closed'}
variants={variants} variants={variants}
className={styles.panel} className={styles.panel}
> >
@ -125,11 +128,3 @@ const Dialog = ({
</AnimatePresence> </AnimatePresence>
); );
}; };
const _Dialog = Object.assign(Dialog, {
Confirm: ConfirmationDialog,
Footer: DialogFooter,
Icon: DialogIcon,
});
export default _Dialog;

View file

@ -1,5 +1,5 @@
import React, { useContext } from 'react'; import React, { useContext } from 'react';
import DialogContext from '@/components/elements/dialog/context'; import { DialogContext } from './';
import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect'; import { useDeepCompareEffect } from '@/plugins/useDeepCompareEffect';
export default ({ children }: { children: React.ReactNode }) => { export default ({ children }: { children: React.ReactNode }) => {

View file

@ -1,16 +1,7 @@
import React, { useContext, useEffect } from 'react'; import React, { useContext, useEffect } from 'react';
import { CheckIcon, ExclamationIcon, InformationCircleIcon, ShieldExclamationIcon } from '@heroicons/react/outline'; import { CheckIcon, ExclamationIcon, InformationCircleIcon, ShieldExclamationIcon } from '@heroicons/react/outline';
import classNames from 'classnames'; import classNames from 'classnames';
import DialogContext from '@/components/elements/dialog/context'; import { DialogContext, DialogIconProps, styles } from './';
import styles from './style.module.css';
export type IconPosition = 'title' | 'container' | undefined;
interface Props {
type: 'danger' | 'info' | 'success' | 'warning';
position?: IconPosition;
className?: string;
}
const icons = { const icons = {
danger: ShieldExclamationIcon, danger: ShieldExclamationIcon,
@ -19,7 +10,7 @@ const icons = {
info: InformationCircleIcon, info: InformationCircleIcon,
}; };
export default ({ type, position, className }: Props) => { export default ({ type, position, className }: DialogIconProps) => {
const { setIcon, setIconPosition } = useContext(DialogContext); const { setIcon, setIconPosition } = useContext(DialogContext);
useEffect(() => { useEffect(() => {

View file

@ -1,18 +1,14 @@
import React from 'react'; import React from 'react';
import { IconPosition } from './DialogIcon'; import { DialogContextType, DialogWrapperContextType } from './types';
type Callback<T> = ((value: T) => void) | React.Dispatch<React.SetStateAction<T>>; export const DialogContext = React.createContext<DialogContextType>({
interface DialogContextType {
setIcon: Callback<React.ReactNode>;
setFooter: Callback<React.ReactNode>;
setIconPosition: Callback<IconPosition>;
}
const DialogContext = React.createContext<DialogContextType>({
setIcon: () => null, setIcon: () => null,
setFooter: () => null, setFooter: () => null,
setIconPosition: () => null, setIconPosition: () => null,
}); });
export default DialogContext; export const DialogWrapperContext = React.createContext<DialogWrapperContextType>({
props: {},
setProps: () => null,
close: () => null,
});

View file

@ -1 +1,15 @@
export { default as Dialog } from './Dialog'; import DialogComponent from './Dialog';
import DialogFooter from './DialogFooter';
import DialogIcon from './DialogIcon';
import ConfirmationDialog from './ConfirmationDialog';
const Dialog = Object.assign(DialogComponent, {
Confirm: ConfirmationDialog,
Footer: DialogFooter,
Icon: DialogIcon,
});
export { Dialog };
export * from './types.d';
export * from './context';
export { default as styles } from './style.module.css';

View file

@ -0,0 +1,38 @@
import React from 'react';
import { IconPosition } from '@/components/elements/dialog/DialogIcon';
type Callback<T> = ((value: T) => void) | React.Dispatch<React.SetStateAction<T>>;
export interface DialogProps {
open: boolean;
onClose: () => void;
}
export type IconPosition = 'title' | 'container' | undefined;
export interface DialogIconProps {
type: 'danger' | 'info' | 'success' | 'warning';
position?: IconPosition;
className?: string;
}
export interface RenderDialogProps extends DialogProps {
hideCloseIcon?: boolean;
preventExternalClose?: boolean;
title?: string;
description?: string | undefined;
children?: React.ReactNode;
}
export type WrapperProps = Omit<RenderDialogProps, 'children' | 'open' | 'onClose'>;
export interface DialogWrapperContextType {
props: Readonly<WrapperProps>;
setProps: Callback<Partial<WrapperProps>>;
close: () => void;
}
export interface DialogContextType {
setIcon: Callback<React.ReactNode>;
setFooter: Callback<React.ReactNode>;
setIconPosition: Callback<IconPosition>;
}

View file

@ -0,0 +1,23 @@
import React, { useState } from 'react';
import { Dialog, DialogProps, DialogWrapperContext, WrapperProps } from '@/components/elements/dialog';
function asDialog(
initialProps?: WrapperProps
// eslint-disable-next-line @typescript-eslint/ban-types
): <P extends {}>(C: React.ComponentType<P>) => React.FunctionComponent<P & DialogProps> {
return function (Component) {
return function ({ open, onClose, ...rest }) {
const [props, setProps] = useState<WrapperProps>(initialProps || {});
return (
<DialogWrapperContext.Provider value={{ props, setProps, close: onClose }}>
<Dialog {...props} open={open} onClose={onClose}>
<Component {...(rest as React.ComponentProps<typeof Component>)} />
</Dialog>
</DialogWrapperContext.Provider>
);
};
};
}
export default asDialog;