1'use client';
2
3import { AnimatePresence, type HTMLMotionProps, motion, useReducedMotion } from 'motion/react';
4import * as React from 'react';
5import { cloneElement } from 'react';
6import { createPortal } from 'react-dom';
7import { cn } from '../lib/cn';
8import {
9 assignRef,
10 focusFirst,
11 lockPageScroll,
12 trapFocus,
13 useIsClient,
14 useLayer,
15} from '../lib/overlay';
16import { backdrop, reveal, surface } from '../lib/motion';
17import { useControllableState } from '../lib/use-controllable-state';
18
19type IconProps = React.SVGProps<SVGSVGElement> & {
20 size?: number | string;
21};
22
23function XIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {
24 return (
25 <svg
26 xmlns="http://www.w3.org/2000/svg"
27 width={size}
28 height={size}
29 viewBox="0 0 24 24"
30 fill="none"
31 stroke="currentColor"
32 strokeWidth={strokeWidth}
33 strokeLinecap="round"
34 strokeLinejoin="round"
35 aria-hidden="true"
36 {...props}
37 >
38 <path d="M18 6 6 18" />
39 <path d="m6 6 12 12" />
40 </svg>
41 );
42}
43
44const OVERLAY_STYLE = { willChange: 'opacity' } as const;
45const DIALOG_STYLE = { willChange: 'opacity, transform, filter' } as const;
46
47type SlotChildProps = Record<string, unknown> & { ref?: React.Ref<HTMLElement> };
48
49function mergeSlotProps(own: Record<string, unknown>, child: SlotChildProps) {
50 const merged: Record<string, unknown> = { ...own, ...child };
51 for (const [key, ownValue] of Object.entries(own)) {
52 const childValue = child[key];
53 if (
54 /^on[A-Z]/.test(key) &&
55 typeof ownValue === 'function' &&
56 typeof childValue === 'function'
57 ) {
58 merged[key] = (...args: unknown[]) => {
59 childValue(...args);
60 ownValue(...args);
61 };
62 }
63 }
64 if (own.className || child.className) {
65 merged.className = cn(
66 own.className as string | undefined,
67 child.className as string | undefined,
68 );
69 }
70 if (own.style || child.style) {
71 merged.style = { ...(own.style as object | undefined), ...(child.style as object | undefined) };
72 }
73 return merged;
74}
75
76function useComposedRef<T extends HTMLElement>(
77 first: React.Ref<T> | undefined,
78 second: React.Ref<T> | React.Ref<HTMLElement> | undefined,
79) {
80 return React.useCallback(
81 (node: T | null) => {
82 assignRef(first, node);
83 assignRef(second as React.Ref<T> | undefined, node);
84 },
85 [first, second],
86 );
87}
88
89type DialogContextType = {
90 open: boolean;
91 setOpen: React.Dispatch<React.SetStateAction<boolean>>;
92 id: string;
93 hasTitle: boolean;
94 setHasTitle: (value: boolean) => void;
95 hasDescription: boolean;
96 setHasDescription: (value: boolean) => void;
97};
98
99const DialogContext = React.createContext<DialogContextType | undefined>(undefined);
100
101const useDialogContext = () => {
102 const context = React.use(DialogContext);
103 if (!context) {
104 throw new Error('useDialogContext must be used within a Dialog');
105 }
106 return context;
107};
108
109interface DialogProps {
110 children: React.ReactNode;
111 defaultOpen?: boolean;
112 open?: boolean;
113 onOpenChange?: (open: boolean) => void;
114}
115
116const DialogRoot = ({
117 children,
118 defaultOpen = false,
119 open: controlledOpen,
120 onOpenChange,
121}: DialogProps) => {
122 const [open, setOpen] = useControllableState({
123 value: controlledOpen,
124 defaultValue: defaultOpen,
125 onChange: onOpenChange,
126 });
127 const [hasTitle, setHasTitle] = React.useState(false);
128 const [hasDescription, setHasDescription] = React.useState(false);
129 const id = React.useId();
130
131 const value = React.useMemo<DialogContextType>(
132 () => ({ open, setOpen, id, hasTitle, setHasTitle, hasDescription, setHasDescription }),
133 [open, setOpen, id, hasTitle, hasDescription],
134 );
135
136 return <DialogContext value={value}>{children}</DialogContext>;
137};
138
139interface DialogTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
140 asChild?: boolean;
141}
142
143const DialogTrigger = ({
144 asChild,
145 onClick,
146 children,
147 ref,
148 ...props
149}: DialogTriggerProps & { ref?: React.Ref<HTMLButtonElement> }) => {
150 const { open, setOpen } = useDialogContext();
151 const slot = asChild && React.isValidElement<SlotChildProps>(children) ? children : null;
152 const composedRef = useComposedRef<HTMLButtonElement>(ref, slot?.props.ref);
153
154 const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
155 setOpen(true);
156 onClick?.(e);
157 };
158
159 if (slot) {
160 return cloneElement(slot, {
161 ...mergeSlotProps({ ...props, onClick: handleClick }, slot.props),
162 'aria-haspopup': 'dialog',
163 'aria-expanded': open,
164 ref: composedRef,
165 });
166 }
167
168 return (
169 <button
170 type="button"
171 aria-haspopup="dialog"
172 aria-expanded={open}
173 onClick={handleClick}
174 ref={ref}
175 {...props}
176 >
177 {children}
178 </button>
179 );
180};
181DialogTrigger.displayName = 'DialogTrigger';
182
183interface DialogPortalProps {
184 children: React.ReactNode;
185}
186
187const DialogPortal = ({ children }: DialogPortalProps) => {
188 const mounted = useIsClient();
189
190 if (!mounted) return null;
191
192 return createPortal(children, document.body);
193};
194
195interface DialogCloseProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
196 asChild?: boolean;
197}
198
199const DialogClose = ({
200 asChild,
201 onClick,
202 children,
203 ref,
204 ...props
205}: DialogCloseProps & { ref?: React.Ref<HTMLButtonElement> }) => {
206 const { setOpen } = useDialogContext();
207 const slot = asChild && React.isValidElement<SlotChildProps>(children) ? children : null;
208 const composedRef = useComposedRef<HTMLButtonElement>(ref, slot?.props.ref);
209
210 const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
211 setOpen(false);
212 onClick?.(e);
213 };
214
215 if (slot) {
216 return cloneElement(slot, {
217 ...mergeSlotProps({ ...props, onClick: handleClick }, slot.props),
218 ref: composedRef,
219 });
220 }
221
222 return (
223 <button type="button" onClick={handleClick} ref={ref} {...props}>
224 {children}
225 </button>
226 );
227};
228DialogClose.displayName = 'DialogClose';
229
230type DialogOverlayProps = HTMLMotionProps<'div'> & {
231 className?: string;
232};
233
234const DialogOverlay = ({
235 className,
236 ref,
237 ...props
238}: DialogOverlayProps & { ref?: React.Ref<HTMLDivElement> }) => {
239 const { setOpen } = useDialogContext();
240 const shouldReduceMotion = useReducedMotion();
241
242 return (
243 <motion.div
244 ref={ref}
245 variants={shouldReduceMotion ? reveal : backdrop}
246 initial="hidden"
247 animate="visible"
248 exit="exit"
249 style={OVERLAY_STYLE}
250 className={cn('bg-overlay fixed inset-0 z-500', className)}
251 onClick={() => setOpen(false)}
252 {...props}
253 />
254 );
255};
256DialogOverlay.displayName = 'DialogOverlay';
257
258interface DialogContentProps extends HTMLMotionProps<'div'> {
259 children?: React.ReactNode;
260 showClose?: boolean;
261 closeLabel?: string;
262}
263
264const DialogContent = ({
265 className,
266 children,
267 showClose = true,
268 closeLabel = 'Close',
269 ref,
270 ...props
271}: DialogContentProps & { ref?: React.Ref<HTMLDivElement> }) => {
272 const { id, open, setOpen, hasTitle, hasDescription } = useDialogContext();
273 const contentRef = React.useRef<HTMLDivElement>(null);
274 const composedRef = useComposedRef<HTMLDivElement>(contentRef, ref);
275 const layer = useLayer(open, true);
276 const triggerRef = React.useRef<HTMLElement | null>(null);
277 const shouldReduceMotion = useReducedMotion();
278
279 React.useEffect(() => {
280 if (!open) return;
281
282 triggerRef.current = document.activeElement as HTMLElement;
283
284 const handleKeyDown = (e: KeyboardEvent) => {
285 if (e.defaultPrevented) return;
286 const content = contentRef.current;
287 if (!content) return;
288
289 if (e.key === 'Escape') {
290 if (!layer.isTopmost()) return;
291 e.preventDefault();
292 setOpen(false);
293 return;
294 }
295 if (layer.isTopmostModal() && !layer.isInsideUpperLayer(e.target)) trapFocus(e, content);
296 };
297
298 document.addEventListener('keydown', handleKeyDown);
299
300 const frame = requestAnimationFrame(() => {
301 if (contentRef.current) focusFirst(contentRef.current);
302 });
303
304 const releasePageScroll = lockPageScroll();
305
306 return () => {
307 cancelAnimationFrame(frame);
308 document.removeEventListener('keydown', handleKeyDown);
309 releasePageScroll();
310 triggerRef.current?.focus();
311 };
312 }, [open, setOpen, layer]);
313
314 return (
315 <AnimatePresence mode="wait">
316 {open && (
317 <DialogPortal>
318 <DialogOverlay />
319 <motion.div
320 ref={composedRef}
321 role="dialog"
322 aria-modal="true"
323 aria-labelledby={hasTitle ? `${id}-title` : undefined}
324 aria-describedby={hasDescription ? `${id}-description` : undefined}
325 tabIndex={-1}
326 onClick={(e) => e.stopPropagation()}
327 variants={shouldReduceMotion ? reveal : surface}
328 initial="hidden"
329 animate="visible"
330 exit="exit"
331 style={DIALOG_STYLE}
332 className={cn(
333 'bg-background fixed top-[50%] left-[50%] z-500 grid max-h-[calc(100dvh-2rem)] w-[calc(100%-2rem)] max-w-xl translate-x-[-50%] translate-y-[-50%] gap-4 overflow-y-auto rounded-lg border p-6 shadow-lg outline-none',
334 className,
335 )}
336 {...props}
337 >
338 {children}
339 {showClose && (
340 <DialogClose className="ring-offset-background focus-visible:ring-ring text-muted-foreground hover:text-foreground absolute top-2 right-2 rounded-sm p-2 transition-colors focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:outline-none disabled:pointer-events-none">
341 <XIcon className="h-4 w-4" size={16} />
342 <span className="sr-only">{closeLabel}</span>
343 </DialogClose>
344 )}
345 </motion.div>
346 </DialogPortal>
347 )}
348 </AnimatePresence>
349 );
350};
351DialogContent.displayName = 'DialogContent';
352
353type DialogHeaderProps = React.HTMLAttributes<HTMLDivElement>;
354
355const DialogHeader = ({ className, ...props }: DialogHeaderProps) => {
356 return (
357 <div
358 className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
359 {...props}
360 />
361 );
362};
363DialogHeader.displayName = 'DialogHeader';
364
365type DialogFooterProps = React.HTMLAttributes<HTMLDivElement>;
366
367const DialogFooter = ({ className, ...props }: DialogFooterProps) => {
368 return (
369 <div
370 className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
371 {...props}
372 />
373 );
374};
375DialogFooter.displayName = 'DialogFooter';
376
377type DialogTitleProps = React.HTMLAttributes<HTMLHeadingElement>;
378
379const DialogTitle = ({
380 className,
381 children,
382 ref,
383 ...props
384}: DialogTitleProps & { ref?: React.Ref<HTMLHeadingElement> }) => {
385 const { id, setHasTitle } = useDialogContext();
386
387 React.useEffect(() => {
388 setHasTitle(true);
389 return () => setHasTitle(false);
390 }, [setHasTitle]);
391
392 return (
393 <h2
394 id={`${id}-title`}
395 ref={ref}
396 className={cn('text-lg leading-none font-semibold tracking-tight', className)}
397 {...props}
398 >
399 {children}
400 </h2>
401 );
402};
403DialogTitle.displayName = 'DialogTitle';
404
405type DialogDescriptionProps = React.HTMLAttributes<HTMLParagraphElement>;
406
407const DialogDescription = ({
408 className,
409 ref,
410 ...props
411}: DialogDescriptionProps & { ref?: React.Ref<HTMLParagraphElement> }) => {
412 const { id, setHasDescription } = useDialogContext();
413
414 React.useEffect(() => {
415 setHasDescription(true);
416 return () => setHasDescription(false);
417 }, [setHasDescription]);
418
419 return (
420 <p
421 id={`${id}-description`}
422 ref={ref}
423 className={cn('text-muted-foreground text-sm', className)}
424 {...props}
425 />
426 );
427};
428DialogDescription.displayName = 'DialogDescription';
429
430const Dialog = Object.assign(DialogRoot, {
431 Trigger: DialogTrigger,
432 Portal: DialogPortal,
433 Overlay: DialogOverlay,
434 Close: DialogClose,
435 Content: DialogContent,
436 Header: DialogHeader,
437 Footer: DialogFooter,
438 Title: DialogTitle,
439 Description: DialogDescription,
440});
441
442export { Dialog };