-
Notifications
You must be signed in to change notification settings - Fork 63
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[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
1 parent
bcb5a8c
commit d1b921a
Showing
5 changed files
with
331 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'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> | ||
</> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
116 changes: 116 additions & 0 deletions
116
src/views/components/common/DialogProvider/DialogProvider.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); |