YT-AI-Automation / frontend /src /components /ConfirmDialog.tsx
github-actions
Sync Docker Space
5f3e9f5
/**
* Promise-based confirmation dialog (replacement for `window.confirm`).
*
* Use the imperative `useConfirm()` hook to show a dialog and await the
* user's choice. Internally rendered through a portal so it works above
* any other content.
*/
import { createContext, useCallback, useContext, useMemo, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useFocusTrap } from '../hooks/useFocusTrap'
interface ConfirmOptions {
title: string
message?: ReactNode
confirmLabel?: string
cancelLabel?: string
variant?: 'default' | 'danger'
}
type Resolver = (value: boolean) => void
interface ConfirmContextValue {
confirm: (opts: ConfirmOptions) => Promise<boolean>
}
const ConfirmContext = createContext<ConfirmContextValue | null>(null)
// eslint-disable-next-line react-refresh/only-export-components
export function useConfirm(): (opts: ConfirmOptions) => Promise<boolean> {
const ctx = useContext(ConfirmContext)
if (!ctx) throw new Error('useConfirm must be used inside <ConfirmProvider>')
return ctx.confirm
}
export function ConfirmProvider({ children }: { children: ReactNode }) {
const [opts, setOpts] = useState<ConfirmOptions | null>(null)
const resolverRef = useRef<Resolver | null>(null)
const dialogRef = useFocusTrap<HTMLDivElement>(opts !== null)
const confirm = useCallback((options: ConfirmOptions) => {
return new Promise<boolean>((resolve) => {
resolverRef.current = resolve
setOpts(options)
})
}, [])
const close = (result: boolean) => {
resolverRef.current?.(result)
resolverRef.current = null
setOpts(null)
}
const value = useMemo<ConfirmContextValue>(() => ({ confirm }), [confirm])
return (
<ConfirmContext.Provider value={value}>
{children}
{opts !== null &&
createPortal(
<div className="fixed inset-0 z-[70] flex items-center justify-center p-4">
<div
className="absolute inset-0 bg-slate-950/40 backdrop-blur-sm"
onClick={() => close(false)}
aria-hidden
/>
<div
ref={dialogRef}
role="alertdialog"
aria-modal="true"
aria-labelledby="confirm-title"
tabIndex={-1}
className="relative w-full max-w-sm rounded-xl border border-slate-200 bg-white p-5 shadow-lg dark:border-white/10 dark:bg-slate-900"
onKeyDown={(e) => {
if (e.key === 'Escape') close(false)
if (e.key === 'Enter') close(true)
}}
>
<h2
id="confirm-title"
className="font-display text-base font-semibold text-slate-900 dark:text-slate-50"
>
{opts.title}
</h2>
{opts.message && (
<div className="mt-2 text-sm text-slate-600 dark:text-slate-400">
{opts.message}
</div>
)}
<div className="mt-5 flex items-center justify-end gap-2">
<button type="button" className="btn-secondary" onClick={() => close(false)}>
{opts.cancelLabel ?? 'Cancel'}
</button>
<button
type="button"
className={
opts.variant === 'danger'
? 'btn-primary !bg-rose-600 hover:!bg-rose-700 focus:!ring-rose-500'
: 'btn-primary'
}
onClick={() => close(true)}
autoFocus
>
{opts.confirmLabel ?? 'Confirm'}
</button>
</div>
</div>
</div>,
document.body,
)}
</ConfirmContext.Provider>
)
}