215 lines
6.0 KiB
TypeScript
215 lines
6.0 KiB
TypeScript
|
|
import * as React from "react";
|
||
|
|
import * as RadixDialog from "@radix-ui/react-dialog";
|
||
|
|
import { X } from "lucide-react";
|
||
|
|
import { cn } from "@/lib/utils";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Dialog primitive — thin styled wrapper over @radix-ui/react-dialog.
|
||
|
|
* Radix handles focus trap, scroll lock, Escape, focus restoration, and ARIA.
|
||
|
|
*
|
||
|
|
* The exported API mirrors the previous custom implementation so consumers
|
||
|
|
* (Drawer, CommandPalette, ShortcutsOverlay) didn't need restructuring:
|
||
|
|
*
|
||
|
|
* <Dialog open onOpenChange>
|
||
|
|
* <DialogPanel ariaLabel="…">
|
||
|
|
* <DialogHeader onClose>...</DialogHeader>
|
||
|
|
* <DialogBody>...</DialogBody>
|
||
|
|
* <DialogFooter>...</DialogFooter>
|
||
|
|
* </DialogPanel>
|
||
|
|
* </Dialog>
|
||
|
|
*/
|
||
|
|
|
||
|
|
interface DialogProps {
|
||
|
|
open: boolean;
|
||
|
|
onOpenChange: (open: boolean) => void;
|
||
|
|
children: React.ReactNode;
|
||
|
|
/** Compatibility shim — ariaLabel is now supplied on DialogPanel directly. */
|
||
|
|
ariaLabel?: string;
|
||
|
|
/** Block close from overlay click */
|
||
|
|
closeOnOverlayClick?: boolean;
|
||
|
|
/** Block close from Escape */
|
||
|
|
closeOnEscape?: boolean;
|
||
|
|
}
|
||
|
|
|
||
|
|
const DialogCtx = React.createContext<{
|
||
|
|
ariaLabel?: string;
|
||
|
|
closeOnOverlayClick: boolean;
|
||
|
|
closeOnEscape: boolean;
|
||
|
|
}>({ closeOnOverlayClick: true, closeOnEscape: true });
|
||
|
|
|
||
|
|
export function Dialog({
|
||
|
|
open,
|
||
|
|
onOpenChange,
|
||
|
|
children,
|
||
|
|
ariaLabel,
|
||
|
|
closeOnOverlayClick = true,
|
||
|
|
closeOnEscape = true,
|
||
|
|
}: DialogProps) {
|
||
|
|
return (
|
||
|
|
<DialogCtx.Provider
|
||
|
|
value={{ ariaLabel, closeOnOverlayClick, closeOnEscape }}
|
||
|
|
>
|
||
|
|
<RadixDialog.Root open={open} onOpenChange={onOpenChange}>
|
||
|
|
{children}
|
||
|
|
</RadixDialog.Root>
|
||
|
|
</DialogCtx.Provider>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export const DialogTrigger = RadixDialog.Trigger;
|
||
|
|
export const DialogClose = RadixDialog.Close;
|
||
|
|
|
||
|
|
interface DialogPanelProps extends React.HTMLAttributes<HTMLDivElement> {
|
||
|
|
/** Accessible label when the panel has no DialogTitle */
|
||
|
|
ariaLabel?: string;
|
||
|
|
/** Accessible description id reference */
|
||
|
|
ariaDescribedBy?: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
export const DialogPanel = React.forwardRef<HTMLDivElement, DialogPanelProps>(
|
||
|
|
({ className, children, ariaLabel, ariaDescribedBy, ...props }, ref) => {
|
||
|
|
const ctx = React.useContext(DialogCtx);
|
||
|
|
const label = ariaLabel ?? ctx.ariaLabel;
|
||
|
|
return (
|
||
|
|
<RadixDialog.Portal>
|
||
|
|
<RadixDialog.Overlay
|
||
|
|
className={cn(
|
||
|
|
"fixed inset-0 z-50 bg-black/40 backdrop-blur-sm",
|
||
|
|
"data-[state=open]:animate-fade-in",
|
||
|
|
)}
|
||
|
|
/>
|
||
|
|
<RadixDialog.Content
|
||
|
|
ref={ref}
|
||
|
|
aria-label={label}
|
||
|
|
aria-describedby={ariaDescribedBy}
|
||
|
|
onInteractOutside={(e) => {
|
||
|
|
if (!ctx.closeOnOverlayClick) e.preventDefault();
|
||
|
|
}}
|
||
|
|
onEscapeKeyDown={(e) => {
|
||
|
|
if (!ctx.closeOnEscape) e.preventDefault();
|
||
|
|
}}
|
||
|
|
className={cn(
|
||
|
|
"fixed left-1/2 top-1/2 z-50 -translate-x-1/2 -translate-y-1/2",
|
||
|
|
"flex max-h-[90vh] w-full max-w-lg flex-col overflow-hidden",
|
||
|
|
"rounded-xl border border-border bg-surface-raised shadow-xl outline-none",
|
||
|
|
"data-[state=open]:animate-fade-in-up",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
{/* Radix warns if no Title is present. If consumer didn't provide one,
|
||
|
|
expose ariaLabel via VisuallyHidden Title as a fallback. */}
|
||
|
|
{label && !hasDialogTitle(children) && (
|
||
|
|
<RadixDialog.Title className="sr-only">{label}</RadixDialog.Title>
|
||
|
|
)}
|
||
|
|
{children}
|
||
|
|
</RadixDialog.Content>
|
||
|
|
</RadixDialog.Portal>
|
||
|
|
);
|
||
|
|
},
|
||
|
|
);
|
||
|
|
DialogPanel.displayName = "DialogPanel";
|
||
|
|
|
||
|
|
/** Best-effort detection: does the children tree include a <DialogTitle>? */
|
||
|
|
function hasDialogTitle(node: React.ReactNode): boolean {
|
||
|
|
let found = false;
|
||
|
|
React.Children.forEach(node, (child) => {
|
||
|
|
if (found) return;
|
||
|
|
if (!React.isValidElement(child)) return;
|
||
|
|
if (child.type === DialogTitle) {
|
||
|
|
found = true;
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const subtree = (child.props as { children?: React.ReactNode } | undefined)
|
||
|
|
?.children;
|
||
|
|
if (subtree && hasDialogTitle(subtree)) found = true;
|
||
|
|
});
|
||
|
|
return found;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function DialogHeader({
|
||
|
|
className,
|
||
|
|
children,
|
||
|
|
onClose,
|
||
|
|
...props
|
||
|
|
}: React.HTMLAttributes<HTMLDivElement> & { onClose?: () => void }) {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex items-start justify-between gap-4 border-b border-border px-5 py-4",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
{...props}
|
||
|
|
>
|
||
|
|
<div className="min-w-0 flex-1">{children}</div>
|
||
|
|
{onClose && (
|
||
|
|
<RadixDialog.Close asChild>
|
||
|
|
<button
|
||
|
|
type="button"
|
||
|
|
onClick={onClose}
|
||
|
|
className="-mr-2 -mt-1 rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
||
|
|
aria-label="Close"
|
||
|
|
>
|
||
|
|
<X className="h-4 w-4" />
|
||
|
|
</button>
|
||
|
|
</RadixDialog.Close>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export const DialogTitle = React.forwardRef<
|
||
|
|
HTMLHeadingElement,
|
||
|
|
React.HTMLAttributes<HTMLHeadingElement>
|
||
|
|
>(({ className, ...props }, ref) => (
|
||
|
|
<RadixDialog.Title asChild>
|
||
|
|
<h2
|
||
|
|
ref={ref}
|
||
|
|
className={cn("text-base font-semibold text-foreground", className)}
|
||
|
|
{...props}
|
||
|
|
/>
|
||
|
|
</RadixDialog.Title>
|
||
|
|
));
|
||
|
|
DialogTitle.displayName = "DialogTitle";
|
||
|
|
|
||
|
|
export const DialogDescription = React.forwardRef<
|
||
|
|
HTMLParagraphElement,
|
||
|
|
React.HTMLAttributes<HTMLParagraphElement>
|
||
|
|
>(({ className, ...props }, ref) => (
|
||
|
|
<RadixDialog.Description asChild>
|
||
|
|
<p
|
||
|
|
ref={ref}
|
||
|
|
className={cn("mt-1 text-sm text-muted-foreground", className)}
|
||
|
|
{...props}
|
||
|
|
/>
|
||
|
|
</RadixDialog.Description>
|
||
|
|
));
|
||
|
|
DialogDescription.displayName = "DialogDescription";
|
||
|
|
|
||
|
|
export function DialogBody({
|
||
|
|
className,
|
||
|
|
...props
|
||
|
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn("flex-1 overflow-y-auto px-5 py-4", className)}
|
||
|
|
{...props}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|
||
|
|
|
||
|
|
export function DialogFooter({
|
||
|
|
className,
|
||
|
|
...props
|
||
|
|
}: React.HTMLAttributes<HTMLDivElement>) {
|
||
|
|
return (
|
||
|
|
<div
|
||
|
|
className={cn(
|
||
|
|
"flex flex-row-reverse items-center gap-2 border-t border-border px-5 py-3",
|
||
|
|
className,
|
||
|
|
)}
|
||
|
|
{...props}
|
||
|
|
/>
|
||
|
|
);
|
||
|
|
}
|