feat(ui): migrate Modal to shadcn Dialog

Replace custom Modal (react-focus-lock + useDialogFocus) with Radix
Dialog primitives as a compatibility wrapper. All 7 consumer dialogs
work without modification. Focus trapping, escape key, and backdrop
click are now handled by Radix natively.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Misaka
2026-04-01 20:35:30 +08:00
parent c1a84ba82a
commit 7c980c4ea9
2 changed files with 192 additions and 71 deletions

View File

@@ -1,13 +1,19 @@
/** /**
* Modal Component * Modal Component
* *
* A reusable modal dialog component. * A compatibility wrapper around shadcn Dialog.
* Provides the same API as the previous custom Modal.
*/ */
import React, { useRef, useMemo, useState } from 'react' import React from 'react'
import { X } from 'lucide-react' import { X } from 'lucide-react'
import FocusLock from 'react-focus-lock' import {
import { useDialogFocus } from '../../hooks/useDialogFocus' Dialog,
DialogContent,
DialogTitle,
DialogClose
} from './shadcn/dialog'
import { cn } from '@renderer/lib/utils'
interface ModalProps { interface ModalProps {
isOpen: boolean isOpen: boolean
@@ -55,78 +61,62 @@ export function Modal({
isAlertDialog = false, isAlertDialog = false,
disableEscapeKey = false, disableEscapeKey = false,
disableBackdropClick = false disableBackdropClick = false
}: ModalProps): React.JSX.Element | null { }: ModalProps): React.JSX.Element {
const dialogRef = useRef<HTMLDivElement>(null)
const [generatedId] = useState(
() => `modal-title-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
)
// Use provided titleId or generated one
const generatedTitleId = useMemo((): string => {
return titleId || generatedId
}, [titleId, generatedId])
// Setup focus management (includes Escape key handling)
const { focusLockProps } = useDialogFocus({
isOpen,
dialogRef,
onClose,
triggerRef,
initialFocusSelector,
shouldCloseOnEscape: !disableEscapeKey
})
if (!isOpen) return null
return ( return (
<FocusLock {...focusLockProps}> <Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose() }}>
<div <DialogContent
className="fixed inset-0 z-50 overflow-y-auto" className={cn(
role={isAlertDialog ? 'alertdialog' : 'dialog'} sizeStyles[size],
aria-modal="true" 'gap-0 p-0'
aria-labelledby={generatedTitleId} )}
aria-describedby={ariaDescribedBy} aria-describedby={ariaDescribedBy}
> role={isAlertDialog ? 'alertdialog' : undefined}
{/* Backdrop */} onEscapeKeyDown={disableEscapeKey ? (e) => e.preventDefault() : undefined}
<div onInteractOutside={disableBackdropClick ? (e) => e.preventDefault() : undefined}
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity" onOpenAutoFocus={
onClick={disableBackdropClick ? undefined : onClose} initialFocusSelector
aria-hidden="true" ? (e) => {
/> e.preventDefault()
const el = document.querySelector(initialFocusSelector) as HTMLElement
{/* Modal container */} el?.focus()
<div className="flex min-h-full items-center justify-center p-4"> }
<div : undefined
ref={dialogRef} }
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`} onCloseAutoFocus={
onClick={(e) => e.stopPropagation()} triggerRef?.current
? (e) => {
e.preventDefault()
triggerRef.current?.focus()
}
: undefined
}
> >
{/* Header */} {/* Header */}
{(title || showCloseButton) && ( {(title || showCloseButton) && (
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200"> <div className="flex items-center justify-between px-6 py-4 border-b border-border">
{title && ( {title && (
<h3 id={generatedTitleId} className="text-lg font-semibold text-gray-900"> <DialogTitle id={titleId} className="text-foreground">
{title} {title}
</h3> </DialogTitle>
)} )}
{showCloseButton && ( {showCloseButton && (
<button <DialogClose
onClick={onClose} className={cn(
className="p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded" 'p-1 text-muted-foreground hover:text-foreground rounded',
'focus:outline-none focus:ring-2 focus:ring-ring'
)}
aria-label="关闭对话框" aria-label="关闭对话框"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </DialogClose>
)} )}
</div> </div>
)} )}
{/* Content */} {/* Content */}
<div className="px-6 py-4">{children}</div> <div className="px-6 py-4">{children}</div>
</div> </DialogContent>
</div> </Dialog>
</div>
</FocusLock>
) )
} }

View File

@@ -0,0 +1,131 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '@renderer/lib/utils'
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />
}
function DialogOverlay({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
'fixed inset-0 z-50 bg-black/50 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=closed]:fade-out-0',
className
)}
{...props}
/>
)
}
function DialogContent({
className,
children,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'fixed left-1/2 top-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 border border-border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 sm:rounded-lg',
className
)}
{...props}
>
{children}
</DialogPrimitive.Content>
</DialogPortal>
)
}
function DialogHeader({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-header"
className={cn('flex flex-col gap-2 text-center sm:text-left', className)}
{...props}
/>
)
}
function DialogFooter({
className,
...props
}: React.ComponentProps<'div'>) {
return (
<div
data-slot="dialog-footer"
className={cn('flex flex-col-reverse gap-2 sm:flex-row sm:justify-end', className)}
{...props}
/>
)
}
function DialogTitle({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn('text-lg font-semibold', className)}
{...props}
/>
)
}
function DialogDescription({
className,
...props
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn('text-muted-foreground text-sm', className)}
{...props}
/>
)
}
export {
Dialog,
DialogTrigger,
DialogClose,
DialogPortal,
DialogOverlay,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription
}