ui(components): New Dialog and Modal components for ui-next (#4772)
Co-authored-by: sedghi <ar.sedghi@gmail.com>
This commit is contained in:
1 parent
62b4fa3e89
commit
a3b51aa8db
90 files changed
+3459
-1357
No files matched your search
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user