From d1b921a5b000693d7f3dabaf84d8b9580c361941 Mon Sep 17 00:00:00 2001 From: Samuel Gunter Date: Mon, 20 May 2024 13:00:00 -0700 Subject: [PATCH] feat: DialogProvider component (#198) * feat: somewhat working DialogProvider * feat: handle multiple dialogs properly, initial focus let's just ignore that showFocus=true doesn't work with "nested" dialogs Co-authored-by: Razboy20 * feat: rework code * feat: add default styles to prompts * refactor: fix stylings --------- Co-authored-by: Razboy20 Co-authored-by: Razboy20 --- .../components/DialogProvider.stories.tsx | 169 ++++++++++++++++++ src/tsconfig.json | 2 +- src/views/components/common/Dialog.tsx | 15 +- .../common/DialogProvider/DialogProvider.tsx | 116 ++++++++++++ src/views/contexts/DialogContext.ts | 33 ++++ 5 files changed, 331 insertions(+), 4 deletions(-) create mode 100644 src/stories/components/DialogProvider.stories.tsx create mode 100644 src/views/components/common/DialogProvider/DialogProvider.tsx create mode 100644 src/views/contexts/DialogContext.ts diff --git a/src/stories/components/DialogProvider.stories.tsx b/src/stories/components/DialogProvider.stories.tsx new file mode 100644 index 00000000..b4018544 --- /dev/null +++ b/src/stories/components/DialogProvider.stories.tsx @@ -0,0 +1,169 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import { Button } from '@views/components/common/Button'; +import DialogProvider, { usePrompt } from '@views/components/common/DialogProvider/DialogProvider'; +import Text from '@views/components/common/Text/Text'; +import React, { useState } from 'react'; + +import MaterialSymbolsExpandAllRoundedIcon from '~icons/material-symbols/expand-all-rounded'; + +const meta = { + title: 'Components/Common/DialogProvider', + component: DialogProvider, + parameters: { + layout: 'centered', + }, + tags: ['autodocs'], + args: {}, + argTypes: {}, +} satisfies Meta; +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { children: undefined }, + render: () => ( + + + + ), +}; + +const InnerComponent = () => { + const showDialog = usePrompt(); + + const myShow = () => { + showDialog({ + title: 'Dialog Title', + description: 'Dialog Description', + // eslint-disable-next-line react/no-unstable-nested-components + buttons: close => ( + + ), + }); + }; + + return ( + + ); +}; + +export const FiveDialogs: Story = { + args: { children: undefined }, + render: () => ( + + They'll open with 100ms delay + + + ), +}; + +const FiveDialogsInnerComponent = () => { + const showDialog = usePrompt(); + + const myShow = () => { + for (let i = 0; i < 5; i++) { + setTimeout( + () => + showDialog({ + title: `Dialog #${i}`, + description: + 'Deleting Main Schedule is permanent and will remove all added courses from that schedule.', + // eslint-disable-next-line react/no-unstable-nested-components + buttons: close => ( + + ), + }), + 100 * i + ); + } + }; + + return ( + + ); +}; + +export const NestedDialogs: Story = { + args: { children: undefined }, + render: () => ( + + + + ), +}; + +const NestedDialogsInnerComponent = () => { + const showDialog = usePrompt(); + + const myShow = () => { + showDialog({ + title: 'Dialog Title', + description: 'Dialog Description', + // eslint-disable-next-line react/no-unstable-nested-components + buttons: close => ( + <> + + + + ), + }); + }; + + return ( + + ); +}; + +export const DialogWithOnClose: Story = { + args: { children: undefined }, + render: () => ( + + + + ), +}; + +const DialogWithOnCloseInnerComponent = () => { + const showDialog = usePrompt(); + const [timesClosed, setTimesClosed] = useState(0); + + const myShow = () => { + showDialog({ + title: 'Dialog Title', + description: 'Dialog Description', + // eslint-disable-next-line react/no-unstable-nested-components + buttons: close => ( + + ), + onClose: () => { + setTimesClosed(prev => prev + 1); + }, + }); + }; + + return ( + <> +

+ You closed the button below {timesClosed} {timesClosed === 1 ? 'time' : 'times'} +

+ + + ); +}; diff --git a/src/tsconfig.json b/src/tsconfig.json index 21fd3d37..3359e97e 100644 --- a/src/tsconfig.json +++ b/src/tsconfig.json @@ -2,7 +2,7 @@ "extends": "../tsconfig.json", "compilerOptions": { "composite": true, - "lib": ["DOM", "es2021"], + "lib": ["DOM", "ESNext"], "types": ["chrome", "node"] } } diff --git a/src/views/components/common/Dialog.tsx b/src/views/components/common/Dialog.tsx index 43023ea4..b8ef0181 100644 --- a/src/views/components/common/Dialog.tsx +++ b/src/views/components/common/Dialog.tsx @@ -10,6 +10,7 @@ export interface _DialogProps { className?: string; title?: JSX.Element; description?: JSX.Element; + initialFocusHidden?: boolean; } /** @@ -21,7 +22,12 @@ export type DialogProps = _DialogProps & Omit): JSX.Element { - const { children, className, open, ...rest } = props; + const { children, className, open, initialFocusHidden, ...rest } = props; + const initialFocusHiddenRef = React.useRef(null); + + if (initialFocusHidden) { + rest.initialFocus = initialFocusHiddenRef; + } return ( @@ -53,8 +59,11 @@ export default function Dialog(props: PropsWithChildren): JSX.Eleme className )} > - {props.title && {props.title}} - {props.description && {props.description}} + {initialFocusHidden &&
} + {props.title && {props.title}} + {props.description && ( + {props.description} + )} {children}
diff --git a/src/views/components/common/DialogProvider/DialogProvider.tsx b/src/views/components/common/DialogProvider/DialogProvider.tsx new file mode 100644 index 00000000..1714cc68 --- /dev/null +++ b/src/views/components/common/DialogProvider/DialogProvider.tsx @@ -0,0 +1,116 @@ +import type { CloseWrapper, DialogInfo, ShowDialogFn } from '@views/contexts/DialogContext'; +import { DialogContext, useDialog } from '@views/contexts/DialogContext'; +import type { ReactNode } from 'react'; +import React, { useCallback, useRef, useState } from 'react'; + +import Dialog from '../Dialog'; +import Text from '../Text/Text'; + +type DialogElement = (show: boolean) => ReactNode; +export interface PromptInfo extends Omit { + title: JSX.Element | string; + description: JSX.Element | string; + onClose?: () => void; + buttons: NonNullable; +} + +function unwrapCloseWrapper(obj: T | CloseWrapper, close: () => void): T { + if (typeof obj === 'function') { + return (obj as CloseWrapper)(close); + } + + return obj; +} + +/** + * Hook to show prompt with default stylings. + */ +export function usePrompt(): (info: PromptInfo) => void { + const showDialog = useDialog(); + + return (info: PromptInfo) => { + showDialog({ + ...info, + title: ( + + {info.title} + + ), + description: ( + + {info.description} + + ), + className: 'max-w-[400px] flex flex-col gap-2.5 p-6.25', + }); + }; +} + +// Unique ID counter is safe to be global +let nextId = 1; + +/** + * Allows descendant to show dialogs via a function, handling animations and stacking. + */ +export default function DialogProvider(props: { children: ReactNode }): JSX.Element { + const dialogQueue = useRef([]); + const [openDialog, setOpenDialog] = useState(); + const openRef = useRef(); + openRef.current = openDialog; + + const [isOpen, setIsOpen] = useState(false); + + const showDialog = useCallback(info => { + const id = nextId++; + + const handleClose = () => { + setIsOpen(false); + }; + + const infoUnwrapped = unwrapCloseWrapper(info, handleClose); + const buttons = unwrapCloseWrapper(infoUnwrapped.buttons, handleClose); + + const onLeave = () => { + setOpenDialog(undefined); + + if (dialogQueue.current.length > 0) { + const newOpen = dialogQueue.current.pop(); + setOpenDialog(() => newOpen); + setIsOpen(true); + } + + infoUnwrapped.onClose?.(); + }; + + const dialogElement = (show: boolean) => ( + +
{buttons}
+
+ ); + + if (openRef.current) { + dialogQueue.current.push(openRef.current); + } + + setOpenDialog(() => dialogElement); + setIsOpen(true); + }, []); + + return ( + + {props.children} + + {openDialog?.(isOpen)} + + ); +} diff --git a/src/views/contexts/DialogContext.ts b/src/views/contexts/DialogContext.ts new file mode 100644 index 00000000..f341bbcf --- /dev/null +++ b/src/views/contexts/DialogContext.ts @@ -0,0 +1,33 @@ +import { createContext, useContext } from 'react'; + +/** + * Close wrapper + */ +export type CloseWrapper = (close: () => void) => T; + +/** + * Information about a dialog. + */ +export interface DialogInfo { + title?: JSX.Element; + description?: JSX.Element; + className?: string; + buttons?: JSX.Element | CloseWrapper; + initialFocusHidden?: boolean; + onClose?: () => void; +} + +/** + * Function to show a dialog. + */ +export type ShowDialogFn = (info: DialogInfo | CloseWrapper) => void; + +/** + * Context for the dialog provider. + */ +export const DialogContext = createContext(() => {}); + +/** + * @returns The dialog context for showing dialogs. + */ +export const useDialog = () => useContext(DialogContext);