ui(components): New Dialog and Modal components for ui-next (#4772)

Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
Dan Rukasandsedghi authored and GitHub committed 2025-02-28 15:17:55 -05:00
1 parent 62b4fa3e89
commit a3b51aa8db
90 files changed
+3459 -1357

No files matched your search

+116 -30
View File
@@ -3,8 +3,41 @@ import * as DialogPrimitive from '@radix-ui/react-dialog';
import { Cross2Icon } from '@radix-ui/react-icons';
import { cn } from '../../lib/utils';
import { useDraggable } from './useDraggable';
const Dialog = DialogPrimitive.Root;
interface DialogContextValue {
isDraggable?: boolean;
shouldCloseOnEsc?: boolean;
shouldCloseOnOverlayClick?: boolean;
showOverlay?: boolean;
}
const DialogContext = React.createContext<DialogContextValue>({
isDraggable: false,
shouldCloseOnEsc: true,
shouldCloseOnOverlayClick: true,
});
interface DialogRootProps extends DialogPrimitive.DialogProps {
isDraggable?: boolean;
shouldCloseOnEsc?: boolean;
shouldCloseOnOverlayClick?: boolean;
showOverlay?: boolean;
}
const Dialog = ({
isDraggable,
shouldCloseOnEsc = true,
shouldCloseOnOverlayClick = true,
showOverlay = true,
...props
}: DialogRootProps) => (
<DialogContext.Provider
value={{ isDraggable, shouldCloseOnEsc, shouldCloseOnOverlayClick, showOverlay }}
>
<DialogPrimitive.Root {...props} />
</DialogContext.Provider>
);
const DialogTrigger = DialogPrimitive.Trigger;
@@ -14,12 +47,14 @@ const DialogClose = DialogPrimitive.Close;
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
<div
ref={ref}
className={cn(
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/80',
'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-40 bg-black/60',
className
)}
{...props}
@@ -27,36 +62,83 @@ const DialogOverlay = React.forwardRef<
));
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
interface DialogContentProps
extends React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> {}
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
DialogContentProps & {
children?: React.ReactNode;
}
>(({ className, children, ...props }, ref) => {
const { isDraggable, shouldCloseOnEsc, shouldCloseOnOverlayClick, showOverlay } =
React.useContext(DialogContext);
const { handlePointerDown, setRefs, initialTransform } = useDraggable(
{
enabled: isDraggable,
},
ref
);
// When not isDraggable, Tailwind centers the dialog.
// When isDraggable, we remove the built‑in centering so our inline transform takes over.
const contentClassName = cn(
'max-w-md w-full bg-muted data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid gap-4 p-4 shadow-lg duration-200 sm:rounded-lg',
!isDraggable ? 'translate-x-[-50%] translate-y-[-50%]' : '',
className
);
const style = isDraggable ? { ...props.style, transform: initialTransform } : props.style;
const content = (
<DialogPrimitive.Content
ref={ref}
ref={setRefs}
className={contentClassName}
{...props}
style={style}
onPointerDown={isDraggable ? handlePointerDown : props.onPointerDown}
onEscapeKeyDown={event => {
if (!shouldCloseOnEsc) {
event.preventDefault();
}
}}
onInteractOutside={event => {
if (!shouldCloseOnOverlayClick) {
event.preventDefault();
}
}}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<Cross2Icon className="text-primary h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
);
return (
<DialogPortal>
{showOverlay && !isDraggable && <DialogOverlay />}
{content}
</DialogPortal>
);
});
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => {
return (
<div
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-lg duration-200 sm:rounded-lg',
'drag-handle relative flex select-none flex-col space-y-1.5 text-center sm:text-left',
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:pointer-events-none">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
));
DialogContent.displayName = DialogPrimitive.Content.displayName;
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...props}
/>
);
{props.children}
</div>
);
};
DialogHeader.displayName = 'DialogHeader';
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
@@ -69,11 +151,13 @@ DialogFooter.displayName = 'DialogFooter';
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-xl font-semibold leading-none tracking-tight', className)}
className={cn('text-primary-light text-xl font-normal leading-none tracking-tight', className)}
{...props}
/>
));
@@ -81,11 +165,13 @@ DialogTitle.displayName = DialogPrimitive.Title.displayName;
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
className?: string;
}
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-muted-foreground text-base', className)}
className={cn('text-base', className)}
{...props}
/>
));
@@ -0,0 +1,108 @@
import * as React from 'react';
interface Offset {
x: number;
y: number;
}
interface DragState {
startX: number;
startY: number;
initialOffset: Offset;
}
interface UseDraggableProps {
enabled?: boolean;
}
interface UseDraggableReturn {
offset: React.RefObject<Offset>;
internalRef: React.RefObject<HTMLDivElement>;
handlePointerDown: (e: React.PointerEvent<HTMLDivElement>) => void;
setRefs: (node: HTMLDivElement) => void;
initialTransform: string | undefined;
}
export function useDraggable(
props: UseDraggableProps,
ref?: React.ForwardedRef<HTMLDivElement>
): UseDraggableReturn {
const { enabled = false } = props;
const offsetRef = React.useRef<Offset>({ x: 0, y: 0 });
const internalRef = React.useRef<HTMLDivElement>(null);
const dragState = React.useRef<DragState | null>(null);
const handlePointerMove = React.useCallback((e: PointerEvent) => {
if (!dragState.current) {
return;
}
const deltaX = e.clientX - dragState.current.startX;
const deltaY = e.clientY - dragState.current.startY;
const newOffset = {
x: dragState.current.initialOffset.x + deltaX,
y: dragState.current.initialOffset.y + deltaY,
};
offsetRef.current = newOffset;
if (internalRef.current) {
internalRef.current.style.transform = `translate(-50%, -50%) translate(${newOffset.x}px, ${newOffset.y}px)`;
}
}, []);
const handlePointerUp = React.useCallback(() => {
if (internalRef.current) {
internalRef.current.style.transition = '';
}
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
dragState.current = null;
}, [handlePointerMove]);
const handlePointerDown = React.useCallback(
(e: React.PointerEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (!target.closest('.drag-handle')) {
return;
}
if (internalRef.current) {
internalRef.current.style.transition = 'none';
}
dragState.current = {
startX: e.clientX,
startY: e.clientY,
initialOffset: { ...offsetRef.current },
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
},
[handlePointerMove, handlePointerUp]
);
const setRefs = React.useCallback(
(node: HTMLDivElement) => {
internalRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
(ref as React.MutableRefObject<HTMLDivElement | null>).current = node;
}
},
[ref]
);
const initialTransform = enabled
? `translate(-50%, -50%) translate(${offsetRef.current.x}px, ${offsetRef.current.y}px)`
: undefined;
return {
offset: offsetRef,
internalRef,
handlePointerDown,
setRefs,
initialTransform,
};
}