Skip to content

Commit

Permalink
feat: DialogProvider component (#198)
Browse files Browse the repository at this point in the history
* 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 <[email protected]>

* feat: rework code

* feat: add default styles to prompts

* refactor: fix stylings

---------

Co-authored-by: Razboy20 <[email protected]>
Co-authored-by: Razboy20 <[email protected]>
  • Loading branch information
3 people authored May 20, 2024
1 parent bcb5a8c commit d1b921a
Show file tree
Hide file tree
Showing 5 changed files with 331 additions and 4 deletions.
169 changes: 169 additions & 0 deletions src/stories/components/DialogProvider.stories.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof DialogProvider>;
export default meta;

type Story = StoryObj<typeof meta>;

export const Default: Story = {
args: { children: undefined },
render: () => (
<DialogProvider>
<InnerComponent />
</DialogProvider>
),
};

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 => (
<Button variant='filled' color='ut-burntorange' onClick={close}>
Close
</Button>
),
});
};

return (
<Button variant='filled' color='ut-burntorange' icon={MaterialSymbolsExpandAllRoundedIcon} onClick={myShow}>
Open Dialog
</Button>
);
};

export const FiveDialogs: Story = {
args: { children: undefined },
render: () => (
<DialogProvider>
<Text variant='p'>They&apos;ll open with 100ms delay</Text>
<FiveDialogsInnerComponent />
</DialogProvider>
),
};

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 => (
<Button variant='filled' color='ut-burntorange' onClick={close}>
Close
</Button>
),
}),
100 * i
);
}
};

return (
<Button variant='filled' color='ut-burntorange' icon={MaterialSymbolsExpandAllRoundedIcon} onClick={myShow}>
Open Dialogs
</Button>
);
};

export const NestedDialogs: Story = {
args: { children: undefined },
render: () => (
<DialogProvider>
<NestedDialogsInnerComponent />
</DialogProvider>
),
};

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 => (
<>
<NestedDialogsInnerComponent />
<Button variant='filled' color='ut-burntorange' onClick={close}>
Close
</Button>
</>
),
});
};

return (
<Button variant='filled' color='ut-burntorange' icon={MaterialSymbolsExpandAllRoundedIcon} onClick={myShow}>
Open Next Dialog
</Button>
);
};

export const DialogWithOnClose: Story = {
args: { children: undefined },
render: () => (
<DialogProvider>
<DialogWithOnCloseInnerComponent />
</DialogProvider>
),
};

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 => (
<Button variant='filled' color='ut-burntorange' onClick={close}>
Close
</Button>
),
onClose: () => {
setTimesClosed(prev => prev + 1);
},
});
};

return (
<>
<h1>
You closed the button below {timesClosed} {timesClosed === 1 ? 'time' : 'times'}
</h1>
<Button variant='filled' color='ut-burntorange' icon={MaterialSymbolsExpandAllRoundedIcon} onClick={myShow}>
Open Dialog
</Button>
</>
);
};
2 changes: 1 addition & 1 deletion src/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"extends": "../tsconfig.json",
"compilerOptions": {
"composite": true,
"lib": ["DOM", "es2021"],
"lib": ["DOM", "ESNext"],
"types": ["chrome", "node"]
}
}
15 changes: 12 additions & 3 deletions src/views/components/common/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface _DialogProps {
className?: string;
title?: JSX.Element;
description?: JSX.Element;
initialFocusHidden?: boolean;
}

/**
Expand All @@ -21,7 +22,12 @@ export type DialogProps = _DialogProps & Omit<TransitionRootProps<typeof HDialog
* A reusable popup component that can be used to display content on the page
*/
export default function Dialog(props: PropsWithChildren<DialogProps>): JSX.Element {
const { children, className, open, ...rest } = props;
const { children, className, open, initialFocusHidden, ...rest } = props;
const initialFocusHiddenRef = React.useRef<HTMLDivElement>(null);

if (initialFocusHidden) {
rest.initialFocus = initialFocusHiddenRef;
}

return (
<Transition show={open} as={HDialog} {...rest}>
Expand Down Expand Up @@ -53,8 +59,11 @@ export default function Dialog(props: PropsWithChildren<DialogProps>): JSX.Eleme
className
)}
>
{props.title && <HDialog.Title>{props.title}</HDialog.Title>}
{props.description && <HDialog.Description>{props.description}</HDialog.Description>}
{initialFocusHidden && <div className='hidden' ref={initialFocusHiddenRef} />}
{props.title && <HDialog.Title as={Fragment}>{props.title}</HDialog.Title>}
{props.description && (
<HDialog.Description as={Fragment}>{props.description}</HDialog.Description>
)}
{children}
</HDialog.Panel>
</div>
Expand Down
116 changes: 116 additions & 0 deletions src/views/components/common/DialogProvider/DialogProvider.tsx
Original file line number Diff line number Diff line change
@@ -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<DialogInfo, 'buttons' | 'className' | 'title' | 'description'> {
title: JSX.Element | string;
description: JSX.Element | string;
onClose?: () => void;
buttons: NonNullable<DialogInfo['buttons']>;
}

function unwrapCloseWrapper<T>(obj: T | CloseWrapper<T>, close: () => void): T {
if (typeof obj === 'function') {
return (obj as CloseWrapper<T>)(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: (
<Text variant='h2' as='h1' className='text-theme-black'>
{info.title}
</Text>
),
description: (
<Text variant='p' as='p' className='text-ut-black'>
{info.description}
</Text>
),
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<DialogElement[]>([]);
const [openDialog, setOpenDialog] = useState<DialogElement | undefined>();
const openRef = useRef<typeof openDialog>();
openRef.current = openDialog;

const [isOpen, setIsOpen] = useState(false);

const showDialog = useCallback<ShowDialogFn>(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) => (
<Dialog
key={id}
onClose={handleClose}
afterLeave={onLeave}
title={infoUnwrapped.title}
description={infoUnwrapped.description}
appear
show={show}
initialFocusHidden={infoUnwrapped.initialFocusHidden}
className={infoUnwrapped.className}
>
<div className='mt-0.75 w-full flex justify-end gap-2.5'>{buttons}</div>
</Dialog>
);

if (openRef.current) {
dialogQueue.current.push(openRef.current);
}

setOpenDialog(() => dialogElement);
setIsOpen(true);
}, []);

return (
<DialogContext.Provider value={showDialog}>
{props.children}

{openDialog?.(isOpen)}
</DialogContext.Provider>
);
}
33 changes: 33 additions & 0 deletions src/views/contexts/DialogContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { createContext, useContext } from 'react';

/**
* Close wrapper
*/
export type CloseWrapper<T> = (close: () => void) => T;

/**
* Information about a dialog.
*/
export interface DialogInfo {
title?: JSX.Element;
description?: JSX.Element;
className?: string;
buttons?: JSX.Element | CloseWrapper<JSX.Element>;
initialFocusHidden?: boolean;
onClose?: () => void;
}

/**
* Function to show a dialog.
*/
export type ShowDialogFn = (info: DialogInfo | CloseWrapper<DialogInfo>) => void;

/**
* Context for the dialog provider.
*/
export const DialogContext = createContext<ShowDialogFn>(() => {});

/**
* @returns The dialog context for showing dialogs.
*/
export const useDialog = () => useContext(DialogContext);

0 comments on commit d1b921a

Please sign in to comment.