329 lines
10 KiB
TypeScript
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 }
|
|
}
|