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
*
* 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 FocusLock from 'react-focus-lock'
import { useDialogFocus } from '../../hooks/useDialogFocus'
import {
Dialog,
DialogContent,
DialogTitle,
DialogClose
} from './shadcn/dialog'
import { cn } from '@renderer/lib/utils'
interface ModalProps {
isOpen: boolean
@@ -55,78 +61,62 @@ export function Modal({
isAlertDialog = false,
disableEscapeKey = false,
disableBackdropClick = false
}: ModalProps): React.JSX.Element | null {
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
}: ModalProps): React.JSX.Element {
return (
<FocusLock {...focusLockProps}>
<div
className="fixed inset-0 z-50 overflow-y-auto"
role={isAlertDialog ? 'alertdialog' : 'dialog'}
aria-modal="true"
aria-labelledby={generatedTitleId}
<Dialog open={isOpen} onOpenChange={(open) => { if (!open) onClose() }}>
<DialogContent
className={cn(
sizeStyles[size],
'gap-0 p-0'
)}
aria-describedby={ariaDescribedBy}
role={isAlertDialog ? 'alertdialog' : undefined}
onEscapeKeyDown={disableEscapeKey ? (e) => e.preventDefault() : undefined}
onInteractOutside={disableBackdropClick ? (e) => e.preventDefault() : undefined}
onOpenAutoFocus={
initialFocusSelector
? (e) => {
e.preventDefault()
const el = document.querySelector(initialFocusSelector) as HTMLElement
el?.focus()
}
: undefined
}
onCloseAutoFocus={
triggerRef?.current
? (e) => {
e.preventDefault()
triggerRef.current?.focus()
}
: undefined
}
>
{/* Backdrop */}
<div
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
onClick={disableBackdropClick ? undefined : onClose}
aria-hidden="true"
/>
{/* Modal container */}
<div className="flex min-h-full items-center justify-center p-4">
<div
ref={dialogRef}
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
{title && (
<h3 id={generatedTitleId} className="text-lg font-semibold text-gray-900">
{title}
</h3>
)}
{showCloseButton && (
<button
onClick={onClose}
className="p-1 text-gray-400 hover:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
aria-label="关闭对话框"
>
<X className="w-5 h-5" />
</button>
)}
</div>
{/* Header */}
{(title || showCloseButton) && (
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
{title && (
<DialogTitle id={titleId} className="text-foreground">
{title}
</DialogTitle>
)}
{showCloseButton && (
<DialogClose
className={cn(
'p-1 text-muted-foreground hover:text-foreground rounded',
'focus:outline-none focus:ring-2 focus:ring-ring'
)}
aria-label="关闭对话框"
>
<X className="w-5 h-5" />
</DialogClose>
)}
{/* Content */}
<div className="px-6 py-4">{children}</div>
</div>
</div>
</div>
</FocusLock>
)}
{/* Content */}
<div className="px-6 py-4">{children}</div>
</DialogContent>
</Dialog>
)
}

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
}