| "use client"; |
|
|
| import { cn } from "@midday/ui/cn"; |
| import { Icons } from "@midday/ui/icons"; |
| import { |
| AnimatePresence, |
| MotionConfig, |
| motion, |
| type Transition, |
| type Variant, |
| } from "motion/react"; |
| import React, { |
| useCallback, |
| useContext, |
| useEffect, |
| useId, |
| useMemo, |
| useRef, |
| useState, |
| } from "react"; |
| import { createPortal } from "react-dom"; |
| import useClickOutside from "./useClickOutside"; |
|
|
| export type MorphingDialogContextType = { |
| isOpen: boolean; |
| setIsOpen: React.Dispatch<React.SetStateAction<boolean>>; |
| uniqueId: string; |
| triggerRef: React.RefObject<HTMLButtonElement | null>; |
| }; |
|
|
| const MorphingDialogContext = |
| React.createContext<MorphingDialogContextType | null>(null); |
|
|
| function useMorphingDialog() { |
| const context = useContext(MorphingDialogContext); |
| if (!context) { |
| throw new Error( |
| "useMorphingDialog must be used within a MorphingDialogProvider", |
| ); |
| } |
| return context; |
| } |
|
|
| export type MorphingDialogProviderProps = { |
| children: React.ReactNode; |
| transition?: Transition; |
| }; |
|
|
| function MorphingDialogProvider({ |
| children, |
| transition, |
| }: MorphingDialogProviderProps) { |
| const [isOpen, setIsOpen] = useState(false); |
| const uniqueId = useId(); |
| const triggerRef = useRef<HTMLButtonElement>(null!); |
|
|
| const contextValue = useMemo( |
| () => ({ |
| isOpen, |
| setIsOpen, |
| uniqueId, |
| triggerRef, |
| }), |
| [isOpen, uniqueId], |
| ); |
|
|
| const defaultTransition: Transition = { |
| type: "spring", |
| stiffness: 200, |
| damping: 24, |
| }; |
|
|
| return ( |
| <MorphingDialogContext.Provider value={contextValue}> |
| <MotionConfig transition={transition || defaultTransition}> |
| {children} |
| </MotionConfig> |
| </MorphingDialogContext.Provider> |
| ); |
| } |
|
|
| export type MorphingDialogProps = { |
| children: React.ReactNode; |
| transition?: Transition; |
| }; |
|
|
| function MorphingDialog({ children, transition }: MorphingDialogProps) { |
| const defaultTransition: Transition = { |
| type: "spring", |
| stiffness: 200, |
| damping: 24, |
| }; |
|
|
| return ( |
| <MorphingDialogProvider> |
| <MotionConfig transition={transition || defaultTransition}> |
| {children} |
| </MotionConfig> |
| </MorphingDialogProvider> |
| ); |
| } |
|
|
| export type MorphingDialogTriggerProps = { |
| children: React.ReactNode; |
| className?: string; |
| style?: React.CSSProperties; |
| triggerRef?: React.RefObject<HTMLButtonElement>; |
| }; |
|
|
| function MorphingDialogTrigger({ |
| children, |
| className, |
| style, |
| triggerRef, |
| }: MorphingDialogTriggerProps) { |
| const { setIsOpen, isOpen, uniqueId } = useMorphingDialog(); |
|
|
| const handleClick = useCallback(() => { |
| setIsOpen(!isOpen); |
| }, [isOpen, setIsOpen]); |
|
|
| const handleKeyDown = useCallback( |
| (event: React.KeyboardEvent) => { |
| if (event.key === "Enter" || event.key === " ") { |
| event.preventDefault(); |
| setIsOpen(!isOpen); |
| } |
| }, |
| [isOpen, setIsOpen], |
| ); |
|
|
| |
| const rotationMatch = |
| typeof style?.transform === "string" |
| ? style.transform.match(/rotate\(([^)]+)\)/) |
| : null; |
| const rotation = rotationMatch?.[1] || null; |
|
|
| const transformTemplate = useCallback( |
| (_transform: string, generatedTransform: string) => { |
| |
| |
| |
| if (rotation) { |
| |
| const cleanedTransform = generatedTransform |
| .replace(/rotate\([^)]+\)/g, "") |
| .trim(); |
| return cleanedTransform |
| ? `${cleanedTransform} rotate(${rotation})` |
| : `rotate(${rotation})`; |
| } |
| return generatedTransform; |
| }, |
| [rotation], |
| ); |
|
|
| |
| const mergedStyle = useMemo(() => { |
| if (!style) return undefined; |
| if (typeof style.transform === "string" && rotation) { |
| const { transform: _, ...restStyle } = style as { |
| transform?: string; |
| [key: string]: any; |
| }; |
| return restStyle; |
| } |
| return style; |
| }, [style, rotation]); |
|
|
| return ( |
| <motion.button |
| ref={triggerRef} |
| layoutId={`dialog-${uniqueId}`} |
| className={cn("relative cursor-pointer", className)} |
| onClick={handleClick} |
| onKeyDown={handleKeyDown} |
| style={mergedStyle} |
| layout |
| transformTemplate={rotation ? transformTemplate : undefined} |
| transition={{ |
| type: "spring", |
| stiffness: 200, |
| damping: 24, |
| }} |
| aria-haspopup="dialog" |
| aria-expanded={isOpen} |
| aria-controls={`motion-ui-morphing-dialog-content-${uniqueId}`} |
| aria-label={`Open dialog ${uniqueId}`} |
| > |
| {children} |
| </motion.button> |
| ); |
| } |
|
|
| export type MorphingDialogContentProps = { |
| children: React.ReactNode; |
| className?: string; |
| style?: React.CSSProperties; |
| }; |
|
|
| function MorphingDialogContent({ |
| children, |
| className, |
| style, |
| }: MorphingDialogContentProps) { |
| const { setIsOpen, isOpen, uniqueId, triggerRef } = useMorphingDialog(); |
| const containerRef = useRef<HTMLDivElement>(null!); |
| const [firstFocusableElement, setFirstFocusableElement] = |
| useState<HTMLElement | null>(null); |
| const [lastFocusableElement, setLastFocusableElement] = |
| useState<HTMLElement | null>(null); |
|
|
| useEffect(() => { |
| const handleKeyDown = (event: KeyboardEvent) => { |
| if (event.key === "Escape") { |
| setIsOpen(false); |
| } |
| if (event.key === "Tab") { |
| if (!firstFocusableElement || !lastFocusableElement) return; |
|
|
| if (event.shiftKey) { |
| if (document.activeElement === firstFocusableElement) { |
| event.preventDefault(); |
| lastFocusableElement.focus(); |
| } |
| } else { |
| if (document.activeElement === lastFocusableElement) { |
| event.preventDefault(); |
| firstFocusableElement.focus(); |
| } |
| } |
| } |
| }; |
|
|
| document.addEventListener("keydown", handleKeyDown); |
|
|
| return () => { |
| document.removeEventListener("keydown", handleKeyDown); |
| }; |
| }, [setIsOpen, firstFocusableElement, lastFocusableElement]); |
|
|
| useEffect(() => { |
| if (isOpen) { |
| document.body.classList.add("overflow-hidden"); |
| const focusableElements = containerRef.current?.querySelectorAll( |
| 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', |
| ); |
| if (focusableElements && focusableElements.length > 0) { |
| setFirstFocusableElement(focusableElements[0] as HTMLElement); |
| setLastFocusableElement( |
| focusableElements[focusableElements.length - 1] as HTMLElement, |
| ); |
| (focusableElements[0] as HTMLElement).focus(); |
| } |
| } else { |
| document.body.classList.remove("overflow-hidden"); |
| triggerRef.current?.focus(); |
| } |
| }, [isOpen, triggerRef]); |
|
|
| useClickOutside(containerRef, () => { |
| if (isOpen) { |
| setIsOpen(false); |
| } |
| }); |
|
|
| return ( |
| <motion.div |
| ref={containerRef} |
| layoutId={`dialog-${uniqueId}`} |
| className={cn("overflow-hidden relative", className)} |
| style={style} |
| layout |
| transition={{ |
| type: "spring", |
| stiffness: 200, |
| damping: 24, |
| }} |
| role="dialog" |
| aria-modal="true" |
| aria-labelledby={`motion-ui-morphing-dialog-title-${uniqueId}`} |
| aria-describedby={`motion-ui-morphing-dialog-description-${uniqueId}`} |
| > |
| {children} |
| </motion.div> |
| ); |
| } |
|
|
| export type MorphingDialogContainerProps = { |
| children: React.ReactNode; |
| className?: string; |
| style?: React.CSSProperties; |
| }; |
|
|
| function MorphingDialogContainer({ children }: MorphingDialogContainerProps) { |
| const { isOpen, uniqueId } = useMorphingDialog(); |
| const [mounted, setMounted] = useState(false); |
|
|
| useEffect(() => { |
| setMounted(true); |
| return () => setMounted(false); |
| }, []); |
|
|
| if (!mounted) return null; |
|
|
| return createPortal( |
| <AnimatePresence initial={false} mode="sync"> |
| {isOpen && ( |
| <> |
| <motion.div |
| key={`backdrop-${uniqueId}`} |
| className="fixed inset-0 bg-white/40 backdrop-blur-sm dark:bg-black/40 z-[9999]" |
| style={{ |
| height: "100dvh", // Dynamic viewport height for mobile Safari |
| }} |
| initial={{ opacity: 0 }} |
| animate={{ opacity: 1 }} |
| exit={{ opacity: 0 }} |
| transition={{ duration: 0.3 }} |
| /> |
| <div className="fixed inset-0 z-[9999] flex items-center justify-center"> |
| {children} |
| </div> |
| </> |
| )} |
| </AnimatePresence>, |
| document.body, |
| ); |
| } |
|
|
| export type MorphingDialogTitleProps = { |
| children: React.ReactNode; |
| className?: string; |
| style?: React.CSSProperties; |
| }; |
|
|
| function MorphingDialogTitle({ |
| children, |
| className, |
| style, |
| }: MorphingDialogTitleProps) { |
| const { uniqueId } = useMorphingDialog(); |
|
|
| return ( |
| <motion.div |
| layoutId={`dialog-title-container-${uniqueId}`} |
| className={className} |
| style={style} |
| layout |
| transition={{ |
| type: "spring", |
| stiffness: 200, |
| damping: 24, |
| }} |
| > |
| {children} |
| </motion.div> |
| ); |
| } |
|
|
| export type MorphingDialogSubtitleProps = { |
| children: React.ReactNode; |
| className?: string; |
| style?: React.CSSProperties; |
| }; |
|
|
| function MorphingDialogSubtitle({ |
| children, |
| className, |
| style, |
| }: MorphingDialogSubtitleProps) { |
| const { uniqueId } = useMorphingDialog(); |
|
|
| return ( |
| <motion.div |
| layoutId={`dialog-subtitle-container-${uniqueId}`} |
| className={className} |
| style={style} |
| layout |
| transition={{ |
| type: "spring", |
| stiffness: 200, |
| damping: 24, |
| }} |
| > |
| {children} |
| </motion.div> |
| ); |
| } |
|
|
| export type MorphingDialogDescriptionProps = { |
| children: React.ReactNode; |
| className?: string; |
| disableLayoutAnimation?: boolean; |
| variants?: { |
| initial: Variant; |
| animate: Variant; |
| exit: Variant; |
| }; |
| }; |
|
|
| function MorphingDialogDescription({ |
| children, |
| className, |
| variants, |
| disableLayoutAnimation, |
| }: MorphingDialogDescriptionProps) { |
| const { uniqueId } = useMorphingDialog(); |
|
|
| return ( |
| <motion.div |
| key={`dialog-description-${uniqueId}`} |
| layoutId={ |
| disableLayoutAnimation |
| ? undefined |
| : `dialog-description-content-${uniqueId}` |
| } |
| variants={variants} |
| className={className} |
| initial="initial" |
| animate="animate" |
| exit="exit" |
| id={`dialog-description-${uniqueId}`} |
| > |
| {children} |
| </motion.div> |
| ); |
| } |
|
|
| export type MorphingDialogImageProps = { |
| src: string; |
| alt: string; |
| className?: string; |
| style?: React.CSSProperties; |
| }; |
|
|
| function MorphingDialogImage({ |
| src, |
| alt, |
| className, |
| style, |
| }: MorphingDialogImageProps) { |
| const { uniqueId } = useMorphingDialog(); |
|
|
| return ( |
| |
| <motion.img |
| src={src} |
| alt={alt} |
| className={cn(className)} |
| layoutId={`dialog-img-${uniqueId}`} |
| style={style} |
| /> |
| ); |
| } |
|
|
| export type MorphingDialogCloseProps = { |
| children?: React.ReactNode; |
| className?: string; |
| variants?: { |
| initial: Variant; |
| animate: Variant; |
| exit: Variant; |
| }; |
| }; |
|
|
| function MorphingDialogClose({ |
| children, |
| className, |
| variants, |
| }: MorphingDialogCloseProps) { |
| const { setIsOpen } = useMorphingDialog(); |
|
|
| const handleClose = useCallback(() => { |
| setIsOpen(false); |
| }, [setIsOpen]); |
|
|
| const buttonContent = children || ( |
| <Icons.Close className="h-6 w-6 text-primary" /> |
| ); |
|
|
| |
| |
| const button = ( |
| <button |
| onClick={handleClose} |
| type="button" |
| aria-label="Close dialog" |
| className={cn("focus:outline-none", className)} |
| style={{ |
| position: "absolute", |
| top: "1.5rem", |
| right: "1.5rem", |
| zIndex: 50, |
| pointerEvents: "auto", |
| }} |
| > |
| {buttonContent} |
| </button> |
| ); |
|
|
| if (variants) { |
| return ( |
| <motion.div |
| initial="initial" |
| animate="animate" |
| exit="exit" |
| variants={variants} |
| layout={false} |
| style={{ |
| position: "absolute", |
| top: "1.5rem", |
| right: "1.5rem", |
| zIndex: 50, |
| pointerEvents: "none", |
| }} |
| > |
| {button} |
| </motion.div> |
| ); |
| } |
|
|
| return button; |
| } |
|
|
| export { |
| MorphingDialog, |
| MorphingDialogTrigger, |
| MorphingDialogContainer, |
| MorphingDialogContent, |
| MorphingDialogClose, |
| MorphingDialogTitle, |
| MorphingDialogSubtitle, |
| MorphingDialogDescription, |
| MorphingDialogImage, |
| }; |
|
|