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 <Razboy20@users.noreply.github.com> * feat: rework code * feat: add default styles to prompts * refactor: fix stylings --------- Co-authored-by: Razboy20 <Razboy20@users.noreply.github.com> Co-authored-by: Razboy20 <razboy20@gmail.com>
This commit is contained in:
169
src/stories/components/DialogProvider.stories.tsx
Normal file
169
src/stories/components/DialogProvider.stories.tsx
Normal file
@@ -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'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,7 +2,7 @@
|
|||||||
"extends": "../tsconfig.json",
|
"extends": "../tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"composite": true,
|
"composite": true,
|
||||||
"lib": ["DOM", "es2021"],
|
"lib": ["DOM", "ESNext"],
|
||||||
"types": ["chrome", "node"]
|
"types": ["chrome", "node"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export interface _DialogProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
title?: JSX.Element;
|
title?: JSX.Element;
|
||||||
description?: JSX.Element;
|
description?: JSX.Element;
|
||||||
|
initialFocusHidden?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -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
|
* A reusable popup component that can be used to display content on the page
|
||||||
*/
|
*/
|
||||||
export default function Dialog(props: PropsWithChildren<DialogProps>): JSX.Element {
|
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 (
|
return (
|
||||||
<Transition show={open} as={HDialog} {...rest}>
|
<Transition show={open} as={HDialog} {...rest}>
|
||||||
@@ -53,8 +59,11 @@ export default function Dialog(props: PropsWithChildren<DialogProps>): JSX.Eleme
|
|||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{props.title && <HDialog.Title>{props.title}</HDialog.Title>}
|
{initialFocusHidden && <div className='hidden' ref={initialFocusHiddenRef} />}
|
||||||
{props.description && <HDialog.Description>{props.description}</HDialog.Description>}
|
{props.title && <HDialog.Title as={Fragment}>{props.title}</HDialog.Title>}
|
||||||
|
{props.description && (
|
||||||
|
<HDialog.Description as={Fragment}>{props.description}</HDialog.Description>
|
||||||
|
)}
|
||||||
{children}
|
{children}
|
||||||
</HDialog.Panel>
|
</HDialog.Panel>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
116
src/views/components/common/DialogProvider/DialogProvider.tsx
Normal file
116
src/views/components/common/DialogProvider/DialogProvider.tsx
Normal file
@@ -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
src/views/contexts/DialogContext.ts
Normal file
33
src/views/contexts/DialogContext.ts
Normal file
@@ -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);
|
||||||
Reference in New Issue
Block a user