このコミットが含まれているのは:
2026-07-15 22:41:06 +09:00
コミット 0ac7332458
4個のファイルの変更331行の追加232行の削除
+186 -105
ファイルの表示
@@ -1,4 +1,4 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { useCallback, useMemo, useRef, useState } from 'react'
import { Button } from '@/components/ui/button'
import { Dialog,
@@ -7,29 +7,16 @@ import { Dialog,
DialogFooter,
DialogHeader,
DialogTitle } from '@/components/ui/dialog'
import { DialogueContext, useDialogue } from '@/lib/dialogues/useDialogue'
import type { FC, ReactNode } from 'react'
type DialogueVariant = 'default' | 'danger'
type ConfirmOptions = { title: string
description?: ReactNode
confirmText?: string
cancelText?: string
variant?: DialogueVariant }
type AlertOptions = { title: string
description?: ReactNode
okText?: string }
type Choice<T extends string> = { value: T
label: string
variant?: DialogueVariant }
type ChoiceOptions<T extends string> = { title: string
description?: ReactNode
choices: Choice<T>[]
cancelText?: string }
import type { AlertOptions,
ChoiceOptions,
ConfirmOptions,
DialogueAPI,
DialogueFormAction,
DialogueFormControls,
DialogueFormOptions } from '@/lib/dialogues/useDialogue'
type DialogueRequest =
| { id: number
@@ -44,13 +31,10 @@ type DialogueRequest =
kind: 'choice'
options: ChoiceOptions<string>
resolve: (value: string | null) => void }
type DialogueAPI =
{ confirm: (options: ConfirmOptions) => Promise<boolean>
alert: (options: AlertOptions) => Promise<void>
choice: <T extends string> (options: ChoiceOptions<T>) => Promise<T | null> }
const DialogueContext = createContext<DialogueAPI | null> (null)
| { id: number
kind: 'form'
options: DialogueFormOptions
resolve: () => void }
let nextDialogueId = 1
@@ -58,21 +42,24 @@ type Props = { children: ReactNode }
const DialogueProvider: FC<Props> = ({ children }) => {
const [queue, setQueue] = useState<DialogueRequest[]> ([])
const [stack, setStack] = useState<DialogueRequest[]> ([])
const [pendingIds, setPendingIds] = useState<number[]> ([])
const [formActions, setFormActions] = useState<Record<number, DialogueFormAction[]>> ({ })
const formControls = useRef<Record<number, DialogueFormControls>> ({ })
const push = useCallback ((request: Omit<DialogueRequest, 'id'>) => {
const id = nextDialogueId
++nextDialogueId
setQueue (q => [...q, { ...request, id } as DialogueRequest])
setStack (current => [...current, { ...request, id } as DialogueRequest])
}, [])
const closeActive = useCallback ((result?: unknown) => {
setQueue (q => {
const [active, ...rest] = q
const closeRequest = useCallback ((id: number, result?: unknown) => {
setStack (current => {
const active = current.find (request => request.id === id)
if (!(active))
return rest
if (active == null)
return current
switch (active.kind)
{
@@ -87,12 +74,28 @@ const DialogueProvider: FC<Props> = ({ children }) => {
case 'choice':
active.resolve ((result ?? null) as string | null)
break
case 'form':
active.resolve ()
break
}
return rest
return current.filter (request => request.id !== id)
})
setPendingIds (current => current.filter (_1 => _1 !== 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 api = useMemo<DialogueAPI> (() => ({
confirm: options => new Promise<boolean> (resolve => {
push ({ kind: 'confirm', options, resolve })
@@ -103,86 +106,164 @@ const DialogueProvider: FC<Props> = ({ children }) => {
choice: options => new Promise (resolve => {
push ({ kind: 'choice',
options: options as ChoiceOptions<string>,
resolve: resolve as (value: string | null) => void })}) }), [push])
resolve: resolve as (value: string | null) => void })
}),
form: options => new Promise<void> (resolve => {
push ({ kind: 'form', options, resolve })
}) }), [push])
const active = queue[0]
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 (_1 => _1 !== id))
}
},
[closeRequest, pendingIds])
return (
<DialogueContext.Provider value={api}>
{children}
<Dialog
open={Boolean (active)}
onOpenChange={open => {
if (!(open))
closeActive (active?.kind !== 'confirm' && null)
}}>
{active && (
<DialogContent className="px-6 pb-6 pt-7">
<DialogHeader className="pl-8">
<DialogTitle>{active.options.title}</DialogTitle>
{stack.map ((request, index) => {
const isTop = index === stack.length - 1
const pending = pendingIds.includes (request.id)
const controls =
request.kind === 'form'
? (formControls.current[request.id] ??= {
close: () => closeRequest (request.id),
setActions: actions => setRequestActions (request.id, actions) })
: null
const requestActions = formActions[request.id] ?? []
{active.options.description && (
<DialogDescription asChild>
<div>{active.options.description}</div>
</DialogDescription>)}
</DialogHeader>
return (
<Dialog
key={request.id}
open
onOpenChange={open => {
if (!(open) && isTop && !(pending))
closeRequest (request.id, request.kind !== 'confirm' && null)
}}>
<DialogContent
className={
request.kind === 'form'
? `flex max-h-[calc(100dvh-1rem)] ${ request.options.size === 'large'
? 'max-w-3xl'
: 'max-w-lg' } flex-col overflow-hidden gap-0`
: 'px-6 pb-6 pt-7'
}
onEscapeKeyDown={event => {
if (pending)
event.preventDefault ()
}}
onPointerDownOutside={event => {
if (pending)
event.preventDefault ()
}}>
{request.kind === 'form'
? (
<>
<DialogHeader className="shrink-0 pl-8 pr-0 pt-1 text-left">
<DialogTitle>{request.options.title}</DialogTitle>
<DialogFooter>
{active.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeActive (false)}>
{active.options.cancelText ?? '取消'}
</Button>
{request.options.description && (
<DialogDescription asChild>
<div>{request.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<Button
variant={(active.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (true)}>
{active.options.confirmText ?? '確定'}
</Button>
</>)}
<div className="min-h-0 flex-1 overflow-y-auto overscroll-contain">
{controls && request.options.body (controls)}
</div>
{active.kind === 'alert' && (
<Button onClick={() => closeActive ()}>
{active.options.okText ?? '確定'}
</Button>)}
<DialogFooter className="shrink-0 pt-4">
<Button
variant="outline"
onClick={() => closeRequest (request.id)}
disabled={pending}>
{request.options.cancelText ?? '取消'}
</Button>
{active.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeActive (null)}>
{active.options.cancelText ?? '取消'}
</Button>
{requestActions.map (action => (
<Button
key={action.label}
variant={action.variant === 'danger'
? 'destructive'
: 'default'}
onClick={() => void handleFormAction (request.id, action)}
disabled={pending || action.disabled}>
{action.label}
</Button>))}
</DialogFooter>
</>)
: (
<>
<DialogHeader className="pl-8">
<DialogTitle>{request.options.title}</DialogTitle>
{active.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeActive (choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</DialogContent>)}
</Dialog>
{request.options.description && (
<DialogDescription asChild>
<div>{request.options.description}</div>
</DialogDescription>)}
</DialogHeader>
<DialogFooter>
{request.kind === 'confirm' && (
<>
<Button
variant="outline"
onClick={() => closeRequest (request.id, false)}>
{request.options.cancelText ?? '取消'}
</Button>
<Button
variant={(request.options.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeRequest (request.id, true)}>
{request.options.confirmText ?? '確定'}
</Button>
</>)}
{request.kind === 'alert' && (
<Button onClick={() => closeRequest (request.id)}>
{request.options.okText ?? '確定'}
</Button>)}
{request.kind === 'choice' && (
<>
<Button
variant="outline"
onClick={() => closeRequest (request.id, null)}>
{request.options.cancelText ?? '取消'}
</Button>
{request.options.choices.map (choice => (
<Button
key={choice.value}
variant={(choice.variant === 'danger')
? 'destructive'
: 'default'}
onClick={() => closeRequest (request.id, choice.value)}>
{choice.label}
</Button>))}
</>)}
</DialogFooter>
</>)}
</DialogContent>
</Dialog>)
})}
</DialogueContext.Provider>)
}
export const useDialogue = () => {
const dialogue = useContext (DialogueContext)
if (!(dialogue))
throw new Error ('useDialogue must be used inside DialogueProvider')
return dialogue
}
export { useDialogue }
export default DialogueProvider