mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-04-24 06:21:34 +00:00
218 lines
7.1 KiB
TypeScript
218 lines
7.1 KiB
TypeScript
import * as React from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
import { cn } from '../../../lib/utils';
|
|
|
|
interface DialogContextValue {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
triggerRef: React.MutableRefObject<HTMLElement | null>;
|
|
}
|
|
|
|
const DialogContext = React.createContext<DialogContextValue | null>(null);
|
|
|
|
function useDialog() {
|
|
const ctx = React.useContext(DialogContext);
|
|
if (!ctx) throw new Error('Dialog components must be used within <Dialog>');
|
|
return ctx;
|
|
}
|
|
|
|
interface DialogProps {
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
defaultOpen?: boolean;
|
|
children: React.ReactNode;
|
|
}
|
|
|
|
const Dialog: React.FC<DialogProps> = ({ open: controlledOpen, onOpenChange: controlledOnOpenChange, defaultOpen = false, children }) => {
|
|
const [internalOpen, setInternalOpen] = React.useState(defaultOpen);
|
|
const triggerRef = React.useRef<HTMLElement | null>(null) as React.MutableRefObject<HTMLElement | null>;
|
|
const isControlled = controlledOpen !== undefined;
|
|
const open = isControlled ? controlledOpen : internalOpen;
|
|
const onOpenChange = React.useCallback(
|
|
(next: boolean) => {
|
|
if (!isControlled) setInternalOpen(next);
|
|
controlledOnOpenChange?.(next);
|
|
},
|
|
[isControlled, controlledOnOpenChange]
|
|
);
|
|
|
|
const value = React.useMemo(() => ({ open, onOpenChange, triggerRef }), [open, onOpenChange]);
|
|
|
|
return <DialogContext.Provider value={value}>{children}</DialogContext.Provider>;
|
|
};
|
|
|
|
const DialogTrigger = React.forwardRef<HTMLButtonElement, React.ButtonHTMLAttributes<HTMLButtonElement> & { asChild?: boolean }>(
|
|
({ onClick, children, asChild, ...props }, ref) => {
|
|
const { onOpenChange, triggerRef } = useDialog();
|
|
|
|
const handleClick = React.useCallback(
|
|
(e: React.MouseEvent<HTMLButtonElement>) => {
|
|
onOpenChange(true);
|
|
onClick?.(e);
|
|
},
|
|
[onOpenChange, onClick]
|
|
);
|
|
|
|
// asChild: clone child element and compose onClick + capture ref
|
|
if (asChild && React.isValidElement(children)) {
|
|
const child = children as React.ReactElement<any>;
|
|
return React.cloneElement(child, {
|
|
onClick: (e: React.MouseEvent<HTMLElement>) => {
|
|
onOpenChange(true);
|
|
child.props.onClick?.(e);
|
|
},
|
|
ref: (node: HTMLElement | null) => {
|
|
triggerRef.current = node;
|
|
// Forward the outer ref
|
|
if (typeof ref === 'function') ref(node as any);
|
|
else if (ref) (ref as React.MutableRefObject<any>).current = node;
|
|
},
|
|
});
|
|
}
|
|
|
|
return (
|
|
<button
|
|
ref={(node) => {
|
|
triggerRef.current = node;
|
|
if (typeof ref === 'function') ref(node);
|
|
else if (ref) ref.current = node;
|
|
}}
|
|
type="button"
|
|
onClick={handleClick}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|
|
);
|
|
DialogTrigger.displayName = 'DialogTrigger';
|
|
|
|
interface DialogContentProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
onEscapeKeyDown?: () => void;
|
|
onPointerDownOutside?: () => void;
|
|
}
|
|
|
|
const FOCUSABLE_SELECTOR = 'a[href], button:not([disabled]), input:not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
|
|
const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
|
|
({ className, children, onEscapeKeyDown, onPointerDownOutside, ...props }, ref) => {
|
|
const { open, onOpenChange, triggerRef } = useDialog();
|
|
const contentRef = React.useRef<HTMLDivElement | null>(null);
|
|
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
|
|
|
// Save the element that had focus before opening, restore on close
|
|
React.useEffect(() => {
|
|
if (open) {
|
|
previousFocusRef.current = document.activeElement as HTMLElement;
|
|
} else if (previousFocusRef.current) {
|
|
// Prefer the trigger, fall back to whatever was focused before
|
|
const restoreTarget = triggerRef.current || previousFocusRef.current;
|
|
restoreTarget?.focus();
|
|
previousFocusRef.current = null;
|
|
}
|
|
}, [open, triggerRef]);
|
|
|
|
React.useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
e.stopPropagation();
|
|
onEscapeKeyDown?.();
|
|
onOpenChange(false);
|
|
return;
|
|
}
|
|
|
|
// Focus trap: Tab / Shift+Tab cycle within the dialog
|
|
if (e.key === 'Tab' && contentRef.current) {
|
|
const focusable = Array.from(
|
|
contentRef.current.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
|
);
|
|
if (focusable.length === 0) return;
|
|
|
|
const first = focusable[0];
|
|
const last = focusable[focusable.length - 1];
|
|
|
|
if (e.shiftKey && document.activeElement === first) {
|
|
e.preventDefault();
|
|
last.focus();
|
|
} else if (!e.shiftKey && document.activeElement === last) {
|
|
e.preventDefault();
|
|
first.focus();
|
|
}
|
|
}
|
|
};
|
|
|
|
document.addEventListener('keydown', handleKeyDown, true);
|
|
|
|
// Prevent body scroll
|
|
const prev = document.body.style.overflow;
|
|
document.body.style.overflow = 'hidden';
|
|
|
|
return () => {
|
|
document.removeEventListener('keydown', handleKeyDown, true);
|
|
document.body.style.overflow = prev;
|
|
};
|
|
}, [open, onOpenChange, onEscapeKeyDown]);
|
|
|
|
// Auto-focus first focusable element on open
|
|
React.useEffect(() => {
|
|
if (open && contentRef.current) {
|
|
// Small delay to let the portal render
|
|
requestAnimationFrame(() => {
|
|
const first = contentRef.current?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
|
|
first?.focus();
|
|
});
|
|
}
|
|
}, [open]);
|
|
|
|
if (!open) return null;
|
|
|
|
return createPortal(
|
|
<div className="fixed inset-0 z-50">
|
|
{/* Overlay */}
|
|
<div
|
|
className="fixed inset-0 animate-dialog-overlay-show bg-black/50 backdrop-blur-sm"
|
|
onClick={() => {
|
|
onPointerDownOutside?.();
|
|
onOpenChange(false);
|
|
}}
|
|
aria-hidden
|
|
/>
|
|
{/* Content */}
|
|
<div
|
|
ref={(node) => {
|
|
contentRef.current = node;
|
|
if (typeof ref === 'function') ref(node);
|
|
else if (ref) (ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
|
|
}}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
className={cn(
|
|
'fixed left-1/2 top-1/2 z-50 w-full max-w-lg -translate-x-1/2 -translate-y-1/2',
|
|
'rounded-xl border bg-popover text-popover-foreground shadow-lg',
|
|
'animate-dialog-content-show',
|
|
className
|
|
)}
|
|
{...props}
|
|
>
|
|
{children}
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
);
|
|
DialogContent.displayName = 'DialogContent';
|
|
|
|
const DialogTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
|
({ className, ...props }, ref) => (
|
|
<h2 ref={ref} className={cn('sr-only', className)} {...props} />
|
|
)
|
|
);
|
|
DialogTitle.displayName = 'DialogTitle';
|
|
|
|
export { Dialog, DialogTrigger, DialogContent, DialogTitle, useDialog };
|