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:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
131
src/renderer/src/components/ui/shadcn/dialog.tsx
Normal file
131
src/renderer/src/components/ui/shadcn/dialog.tsx
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user