356 行
10 KiB
TypeScript
356 行
10 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from 'react'
|
|
|
|
import { Button } from '@/components/ui/button'
|
|
import { Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle } from '@/components/ui/dialog'
|
|
import { DialogueContext } from '@/lib/dialogues/useDialogue'
|
|
|
|
import type { FC, ReactNode } from 'react'
|
|
import type { AlertOptions,
|
|
ChoiceOptions,
|
|
ConfirmOptions,
|
|
DialogueAPI,
|
|
DialogueFormAction,
|
|
DialogueFormControls,
|
|
DialogueFormOptions } from '@/lib/dialogues/useDialogue'
|
|
|
|
type DialogueRequest =
|
|
| { id: number
|
|
kind: 'confirm'
|
|
options: ConfirmOptions
|
|
resolve: (value: boolean) => void }
|
|
| { id: number
|
|
kind: 'alert'
|
|
options: AlertOptions
|
|
resolve: () => void }
|
|
| { id: number
|
|
kind: 'choice'
|
|
options: ChoiceOptions<string>
|
|
resolve: (value: string | null) => void }
|
|
| { id: number
|
|
kind: 'form'
|
|
options: DialogueFormOptions
|
|
resolve: () => void }
|
|
|
|
let nextDialogueId = 1
|
|
|
|
type Props = { children: ReactNode }
|
|
|
|
|
|
const DialogueProvider: FC<Props> = ({ children }) => {
|
|
const [queue, setQueue] = useState<DialogueRequest[]> ([])
|
|
const [pendingIds, setPendingIds] = useState<number[]> ([])
|
|
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
|
|
const formControls = useRef<Record<number, DialogueFormControls>> ({ })
|
|
const [nestedConfirm, setNestedConfirm] = useState<{
|
|
parentId: number
|
|
options: ConfirmOptions
|
|
resolve: (value: boolean) => void } | null> (null)
|
|
|
|
const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
|
|
const id = nextDialogueId
|
|
++nextDialogueId
|
|
|
|
setQueue (current => [...current, { ...request, id } as DialogueRequest])
|
|
}, [])
|
|
|
|
const closeRequest = useCallback ((id: number, result?: unknown) => {
|
|
setQueue (current => {
|
|
const active = current.find (request => request.id === id)
|
|
|
|
if (active == null)
|
|
return current
|
|
|
|
switch (active.kind)
|
|
{
|
|
case 'confirm':
|
|
active.resolve (Boolean (result))
|
|
break
|
|
|
|
case 'alert':
|
|
active.resolve ()
|
|
break
|
|
|
|
case 'choice':
|
|
active.resolve ((result ?? null) as string | null)
|
|
break
|
|
|
|
case 'form':
|
|
active.resolve ()
|
|
break
|
|
}
|
|
|
|
return current.filter (request => request.id !== id)
|
|
})
|
|
setPendingIds (current => current.filter (pendingId => pendingId !== id))
|
|
setFormActions (current => {
|
|
const { [id]: _, ...rest } = current
|
|
return rest
|
|
})
|
|
delete formControls.current[id]
|
|
}, [])
|
|
|
|
const setRequestActions = useCallback (
|
|
(id: number, actions: DialogueFormAction[]) => {
|
|
setFormActions (current => ({ ...current, [id]: actions }))
|
|
},
|
|
[])
|
|
|
|
const openNestedConfirm = useCallback (
|
|
(parentId: number, options: ConfirmOptions) =>
|
|
new Promise<boolean> (resolve => {
|
|
setNestedConfirm ({ parentId, options, resolve })
|
|
}),
|
|
[])
|
|
|
|
const closeNestedConfirm = useCallback ((result: boolean) => {
|
|
setNestedConfirm (current => {
|
|
if (current == null)
|
|
return current
|
|
|
|
current.resolve (result)
|
|
return null
|
|
})
|
|
}, [])
|
|
|
|
const api = useMemo<DialogueAPI> (() => ({
|
|
confirm: options => new Promise<boolean> (resolve => {
|
|
push ({ kind: 'confirm', options, resolve })
|
|
}),
|
|
alert: options => new Promise<void> (resolve => {
|
|
push ({ kind: 'alert', options, resolve })
|
|
}),
|
|
choice: options => new Promise (resolve => {
|
|
push ({ kind: 'choice',
|
|
options: options as ChoiceOptions<string>,
|
|
resolve: resolve as (value: string | null) => void })
|
|
}),
|
|
form: options => new Promise<void> (resolve => {
|
|
push ({ kind: 'form', options, resolve })
|
|
}) }), [push])
|
|
|
|
const handleFormAction = useCallback (
|
|
async (id: number, action: DialogueFormAction) => {
|
|
if (pendingIds.includes (id))
|
|
return
|
|
|
|
setPendingIds (current => [...current, id])
|
|
try
|
|
{
|
|
const shouldClose = await action.onSelect ()
|
|
if (shouldClose !== false)
|
|
closeRequest (id)
|
|
}
|
|
finally
|
|
{
|
|
setPendingIds (current => current.filter (pendingId => pendingId !== id))
|
|
}
|
|
},
|
|
[closeRequest, pendingIds])
|
|
|
|
const active = queue[0]
|
|
const startActions =
|
|
active?.kind === 'form'
|
|
? (formActions[active.id] ?? []).filter (action => action.placement === 'start')
|
|
: []
|
|
const endActions =
|
|
active?.kind === 'form'
|
|
? (formActions[active.id] ?? []).filter (action =>
|
|
action.placement == null || action.placement === 'end')
|
|
: []
|
|
|
|
return (
|
|
<DialogueContext.Provider value={api}>
|
|
{children}
|
|
|
|
{active && (
|
|
<Dialog
|
|
open
|
|
onOpenChange={open => {
|
|
const blocked =
|
|
nestedConfirm?.parentId === active.id
|
|
|| pendingIds.includes (active.id)
|
|
if (!(open) && !(blocked))
|
|
closeRequest (active.id, active.kind !== 'confirm' && null)
|
|
}}>
|
|
<DialogContent
|
|
className={
|
|
active.kind === 'form'
|
|
? `flex max-h-[calc(100dvh-1rem)] ${ active.options.size === 'large'
|
|
? 'max-w-3xl'
|
|
: 'max-w-lg' } flex-col overflow-hidden gap-0`
|
|
: 'px-6 pb-6 pt-7'
|
|
}
|
|
onEscapeKeyDown={event => {
|
|
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
|
|
event.preventDefault ()
|
|
}}
|
|
onPointerDownOutside={event => {
|
|
if (nestedConfirm?.parentId === active.id || pendingIds.includes (active.id))
|
|
event.preventDefault ()
|
|
}}>
|
|
{active.kind === 'form'
|
|
? (
|
|
<>
|
|
<DialogHeader className="shrink-0 pb-4 pl-8 pr-0 pt-1 text-left">
|
|
<DialogTitle>{active.options.title}</DialogTitle>
|
|
|
|
{active.options.description && (
|
|
<DialogDescription asChild>
|
|
<div>{active.options.description}</div>
|
|
</DialogDescription>)}
|
|
</DialogHeader>
|
|
|
|
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
|
|
{active.options.body (
|
|
formControls.current[active.id] ??= {
|
|
close: () => closeRequest (active.id),
|
|
setActions: actions => setRequestActions (active.id, actions),
|
|
confirm: options => openNestedConfirm (active.id, options) })}
|
|
</div>
|
|
|
|
<DialogFooter
|
|
className="shrink-0 flex-col gap-2 pt-4
|
|
md:flex-row md:justify-between md:gap-0 md:space-x-0">
|
|
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
|
|
{startActions.map (action => (
|
|
<Button
|
|
key={action.label}
|
|
className="w-full md:w-auto"
|
|
variant={action.variant === 'danger'
|
|
? 'destructive'
|
|
: 'default'}
|
|
onClick={() => void handleFormAction (active.id, action)}
|
|
disabled={pendingIds.includes (active.id)
|
|
|| nestedConfirm?.parentId === active.id
|
|
|| action.disabled}>
|
|
{action.label}
|
|
</Button>))}
|
|
</div>
|
|
|
|
<div className="flex w-full flex-col gap-2 md:w-auto md:flex-row">
|
|
<Button
|
|
className="w-full md:w-auto"
|
|
variant="outline"
|
|
onClick={() => closeRequest (active.id)}
|
|
disabled={pendingIds.includes (active.id)
|
|
|| nestedConfirm?.parentId === active.id}>
|
|
{active.options.cancelText ?? '取消'}
|
|
</Button>
|
|
|
|
{endActions.map (action => (
|
|
<Button
|
|
key={action.label}
|
|
className="w-full md:w-auto"
|
|
variant={action.variant === 'danger'
|
|
? 'destructive'
|
|
: 'default'}
|
|
onClick={() => void handleFormAction (active.id, action)}
|
|
disabled={pendingIds.includes (active.id)
|
|
|| nestedConfirm?.parentId === active.id
|
|
|| action.disabled}>
|
|
{action.label}
|
|
</Button>))}
|
|
</div>
|
|
</DialogFooter>
|
|
</>)
|
|
: (
|
|
<>
|
|
<DialogHeader className="pl-8">
|
|
<DialogTitle>{active.options.title}</DialogTitle>
|
|
|
|
{active.options.description && (
|
|
<DialogDescription asChild>
|
|
<div>{active.options.description}</div>
|
|
</DialogDescription>)}
|
|
</DialogHeader>
|
|
|
|
<DialogFooter>
|
|
{active.kind === 'confirm' && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => closeRequest (active.id, false)}>
|
|
{active.options.cancelText ?? '取消'}
|
|
</Button>
|
|
|
|
<Button
|
|
variant={(active.options.variant === 'danger')
|
|
? 'destructive'
|
|
: 'default'}
|
|
onClick={() => closeRequest (active.id, true)}>
|
|
{active.options.confirmText ?? '確定'}
|
|
</Button>
|
|
</>)}
|
|
|
|
{active.kind === 'alert' && (
|
|
<Button onClick={() => closeRequest (active.id)}>
|
|
{active.options.okText ?? '確定'}
|
|
</Button>)}
|
|
|
|
{active.kind === 'choice' && (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => closeRequest (active.id, null)}>
|
|
{active.options.cancelText ?? '取消'}
|
|
</Button>
|
|
|
|
{active.options.choices.map (choice => (
|
|
<Button
|
|
key={choice.value}
|
|
variant={(choice.variant === 'danger')
|
|
? 'destructive'
|
|
: 'default'}
|
|
onClick={() => closeRequest (active.id, choice.value)}>
|
|
{choice.label}
|
|
</Button>))}
|
|
</>)}
|
|
</DialogFooter>
|
|
</>)}
|
|
</DialogContent>
|
|
</Dialog>)}
|
|
|
|
{nestedConfirm && (
|
|
<Dialog
|
|
open
|
|
onOpenChange={open => {
|
|
if (!(open))
|
|
closeNestedConfirm (false)
|
|
}}>
|
|
<DialogContent className="px-6 pb-6 pt-7">
|
|
<DialogHeader className="pl-8">
|
|
<DialogTitle>{nestedConfirm.options.title}</DialogTitle>
|
|
|
|
{nestedConfirm.options.description && (
|
|
<DialogDescription asChild>
|
|
<div>{nestedConfirm.options.description}</div>
|
|
</DialogDescription>)}
|
|
</DialogHeader>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => closeNestedConfirm (false)}>
|
|
{nestedConfirm.options.cancelText ?? '取消'}
|
|
</Button>
|
|
|
|
<Button
|
|
variant={nestedConfirm.options.variant === 'danger'
|
|
? 'destructive'
|
|
: 'default'}
|
|
onClick={() => closeNestedConfirm (true)}>
|
|
{nestedConfirm.options.confirmText ?? '確定'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>)}
|
|
</DialogueContext.Provider>)
|
|
}
|
|
export { useDialogue } from '@/lib/dialogues/useDialogue'
|
|
export default DialogueProvider
|