gongxiangp2/components/ConfirmActionDialog.tsx

329 lines
10 KiB
TypeScript

'use client'
import {
type KeyboardEvent,
type ReactNode,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react'
import { createPortal } from 'react-dom'
export type ConfirmActionOptions = {
title: string
description?: ReactNode
confirmLabel?: string
loadingLabel?: string
cancelLabel?: string
tone?: 'primary' | 'danger'
action?: (reason: string) => Promise<void> | void
reason?: {
label?: string
placeholder?: string
helperText?: string
required?: boolean
}
}
export type ConfirmActionResult = {
confirmed: boolean
reason: string
}
type ConfirmActionDialogProps = ConfirmActionOptions & {
open: boolean
loading: boolean
error: string | null
reasonValue: string
onReasonChange: (value: string) => void
onCancel: () => void
onConfirm: () => void
}
const focusableSelector = [
'button:not([disabled])',
'textarea:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'[href]',
'[tabindex]:not([tabindex="-1"])',
].join(',')
export function ConfirmActionDialog({
open,
title,
description,
confirmLabel = '确认',
loadingLabel = '处理中...',
cancelLabel = '取消',
tone = 'primary',
reason,
loading,
error,
reasonValue,
onReasonChange,
onCancel,
onConfirm,
}: ConfirmActionDialogProps) {
const titleId = useId()
const descriptionId = useId()
const backdropRef = useRef<HTMLDivElement>(null)
const dialogRef = useRef<HTMLDivElement>(null)
const reasonRef = useRef<HTMLTextAreaElement>(null)
const cancelRef = useRef<HTMLButtonElement>(null)
useEffect(() => {
if (!open) return
const previousActiveElement = document.activeElement instanceof HTMLElement
? document.activeElement
: null
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
const backgroundElements = Array.from(document.body.children)
.filter((element): element is HTMLElement => (
element instanceof HTMLElement && element !== backdropRef.current
))
.map((element) => ({
element,
inert: element.inert,
ariaHidden: element.getAttribute('aria-hidden'),
}))
backgroundElements.forEach(({ element }) => {
element.inert = true
element.setAttribute('aria-hidden', 'true')
})
const frame = window.requestAnimationFrame(() => {
if (reason) reasonRef.current?.focus()
else cancelRef.current?.focus()
})
return () => {
window.cancelAnimationFrame(frame)
document.body.style.overflow = previousOverflow
backgroundElements.forEach(({ element, inert, ariaHidden }) => {
element.inert = inert
if (ariaHidden === null) element.removeAttribute('aria-hidden')
else element.setAttribute('aria-hidden', ariaHidden)
})
previousActiveElement?.focus()
}
}, [open, reason])
useEffect(() => {
if (open && loading) dialogRef.current?.focus()
}, [loading, open])
if (!open || typeof document === 'undefined') return null
const reasonRequired = Boolean(reason?.required)
const confirmDisabled = reasonRequired && !reasonValue.trim()
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape' && !loading) {
event.preventDefault()
onCancel()
return
}
if (event.key !== 'Tab') return
const focusable = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(focusableSelector) || [],
)
if (focusable.length === 0) {
event.preventDefault()
dialogRef.current?.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (event.shiftKey && document.activeElement === first) {
event.preventDefault()
last.focus()
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault()
first.focus()
}
}
return createPortal(
<div
ref={backdropRef}
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 p-4 backdrop-blur-[2px]"
onMouseDown={(event) => {
if (!loading && event.target === event.currentTarget) onCancel()
}}
>
<div
ref={dialogRef}
role={tone === 'danger' ? 'alertdialog' : 'dialog'}
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
aria-busy={loading}
tabIndex={-1}
onKeyDown={handleKeyDown}
className="max-h-[calc(100dvh-2rem)] w-full max-w-md overflow-y-auto rounded-lg bg-white text-[#1a1c1c] outline-none"
>
<div className="border-b border-[#e2e2e2] px-5 py-4">
<div className="flex items-start gap-3">
<span
className={`mt-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-full ${
tone === 'danger'
? 'bg-[#fff0ee] text-[#b42318]'
: 'bg-[#edf3ff] text-[#0050cb]'
}`}
aria-hidden="true"
>
{tone === 'danger' ? (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v4m0 4h.01M10.3 3.7 2.6 17a2 2 0 0 0 1.7 3h15.4a2 2 0 0 0 1.7-3L13.7 3.7a2 2 0 0 0-3.4 0Z" />
</svg>
) : (
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9h.01M11 12h1v4h1m8-4a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
)}
</span>
<div className="min-w-0">
<h2 id={titleId} className="text-[16px] font-bold leading-6">
{title}
</h2>
{description && (
<div id={descriptionId} className="mt-1 text-[13px] leading-5 text-[#5f5e5e]">
{description}
</div>
)}
</div>
</div>
</div>
{reason && (
<div className="px-5 pt-4">
<label className="grid gap-2 text-[13px] font-semibold text-[#1a1c1c]">
<span>
{reason.label || '操作原因'}
{reasonRequired && <span className="ml-1 text-[#b42318]">*</span>}
</span>
<textarea
ref={reasonRef}
value={reasonValue}
onChange={(event) => onReasonChange(event.target.value)}
required={reasonRequired}
disabled={loading}
maxLength={500}
rows={2}
placeholder={reason.placeholder || '请填写操作原因'}
className="w-full resize-y rounded border border-[#c2c6d8] px-3 py-2 text-[13px] font-normal leading-5 text-[#1a1c1c] outline-none placeholder:text-[#676767] focus:border-[#0050cb] focus:ring-2 focus:ring-[#dbe6ff]"
/>
</label>
{reason.helperText && (
<p className="mt-1.5 text-[12px] leading-5 text-[#5f5e5e]">
{reason.helperText}
</p>
)}
</div>
)}
{error && (
<div role="alert" className="mx-5 mt-4 rounded border border-[#f0b8b8] bg-[#fff5f5] px-3 py-2 text-[12px] leading-5 text-[#93000a]">
{error}
</div>
)}
<div className="flex flex-col-reverse gap-2 px-5 py-4 sm:flex-row sm:justify-end">
<button
ref={cancelRef}
type="button"
disabled={loading}
onClick={onCancel}
className="min-h-11 rounded border border-[#c2c6d8] px-4 text-[13px] font-bold text-[#1a1c1c] hover:bg-[#f5f5f5] focus:outline-none focus:ring-2 focus:ring-[#dbe6ff] disabled:cursor-not-allowed disabled:opacity-45"
>
{cancelLabel}
</button>
<button
type="button"
disabled={confirmDisabled || loading}
onClick={onConfirm}
className={`min-h-11 rounded px-4 text-[13px] font-bold text-white focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:opacity-45 ${
tone === 'danger'
? 'bg-[#b42318] hover:bg-[#931f16] focus:ring-[#ffd5d0]'
: 'bg-[#0050cb] hover:bg-[#003f9e] focus:ring-[#dbe6ff]'
}`}
>
{loading ? loadingLabel : confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
)
}
export function useConfirmActionDialog() {
const [pending, setPending] = useState<ConfirmActionOptions | null>(null)
const [reasonValue, setReasonValue] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const resolverRef = useRef<((result: ConfirmActionResult) => void) | null>(null)
const finish = useCallback((result: ConfirmActionResult) => {
const resolve = resolverRef.current
resolverRef.current = null
setPending(null)
setReasonValue('')
setLoading(false)
setError(null)
resolve?.(result)
}, [])
const confirmAction = useCallback((options: ConfirmActionOptions) => (
new Promise<ConfirmActionResult>((resolve) => {
resolverRef.current?.({ confirmed: false, reason: '' })
resolverRef.current = resolve
setReasonValue('')
setLoading(false)
setError(null)
setPending(options)
})
), [])
useEffect(() => () => {
resolverRef.current?.({ confirmed: false, reason: '' })
resolverRef.current = null
}, [])
const confirmPendingAction = useCallback(async () => {
if (!pending) return
const reason = reasonValue.trim()
if (pending.reason?.required && !reason) return
if (!pending.action) {
finish({ confirmed: true, reason })
return
}
setLoading(true)
setError(null)
try {
await pending.action(reason)
finish({ confirmed: true, reason })
} catch (actionError) {
setError(actionError instanceof Error ? actionError.message : '操作失败,请稍后重试')
setLoading(false)
}
}, [finish, pending, reasonValue])
const confirmActionDialog = pending ? (
<ConfirmActionDialog
{...pending}
open
loading={loading}
error={error}
reasonValue={reasonValue}
onReasonChange={setReasonValue}
onCancel={() => finish({ confirmed: false, reason: '' })}
onConfirm={() => void confirmPendingAction()}
/>
) : null
return { confirmAction, confirmActionDialog }
}