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
|
* 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}
|
||||||
|
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 */}
|
{/* Header */}
|
||||||
<div
|
{(title || showCloseButton) && (
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 transition-opacity"
|
<div className="flex items-center justify-between px-6 py-4 border-b border-border">
|
||||||
onClick={disableBackdropClick ? undefined : onClose}
|
{title && (
|
||||||
aria-hidden="true"
|
<DialogTitle id={titleId} className="text-foreground">
|
||||||
/>
|
{title}
|
||||||
|
</DialogTitle>
|
||||||
{/* Modal container */}
|
)}
|
||||||
<div className="flex min-h-full items-center justify-center p-4">
|
{showCloseButton && (
|
||||||
<div
|
<DialogClose
|
||||||
ref={dialogRef}
|
className={cn(
|
||||||
className={`relative w-full ${sizeStyles[size]} bg-white rounded-lg shadow-xl transform transition-all`}
|
'p-1 text-muted-foreground hover:text-foreground rounded',
|
||||||
onClick={(e) => e.stopPropagation()}
|
'focus:outline-none focus:ring-2 focus:ring-ring'
|
||||||
>
|
)}
|
||||||
{/* Header */}
|
aria-label="关闭对话框"
|
||||||
{(title || showCloseButton) && (
|
>
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
<X className="w-5 h-5" />
|
||||||
{title && (
|
</DialogClose>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Content */}
|
|
||||||
<div className="px-6 py-4">{children}</div>
|
|
||||||
</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