mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-05-28 23:15:33 +08:00
refactor: add primitives, plan mode display, and new session model selector
This commit is contained in:
217
src/shared/view/ui/Dialog.tsx
Normal file
217
src/shared/view/ui/Dialog.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
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 };
|
||||
Reference in New Issue
Block a user