'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 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(null) const dialogRef = useRef(null) const reasonRef = useRef(null) const cancelRef = useRef(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) => { if (event.key === 'Escape' && !loading) { event.preventDefault() onCancel() return } if (event.key !== 'Tab') return const focusable = Array.from( dialogRef.current?.querySelectorAll(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(
{ if (!loading && event.target === event.currentTarget) onCancel() }} >

{title}

{description && (
{description}
)}
{reason && (