{ "name": "dialog", "type": "registry:ui", "registryDependencies": [], "dependencies": [ "motion" ], "devDependencies": [], "tailwind": {}, "cssVars": { "light": {}, "dark": {} }, "files": [ { "path": "dialog.tsx", "content": "'use client';\nimport { AnimatePresence, motion, Transition, Variants } from 'motion/react';\nimport React, { createContext, useContext, useEffect, useRef } from 'react';\nimport { cn } from '@/lib/utils';\nimport { useId } from 'react';\nimport { createPortal } from 'react-dom';\nimport { X } from 'lucide-react';\nimport { usePreventScroll } from '@/hooks/usePreventScroll';\n\nconst DialogContext = createContext<{\n isOpen: boolean;\n setIsOpen: (open: boolean) => void;\n dialogRef: React.RefObject;\n variants: Variants;\n transition?: Transition;\n ids: {\n dialog: string;\n title: string;\n description: string;\n };\n onAnimationComplete: (definition: string) => void;\n handleTrigger: () => void;\n} | null>(null);\n\nconst defaultVariants: Variants = {\n initial: {\n opacity: 0,\n scale: 0.9,\n },\n animate: {\n opacity: 1,\n scale: 1,\n },\n};\n\nconst defaultTransition: Transition = {\n ease: 'easeOut',\n duration: 0.2,\n};\n\nexport type DialogProps = {\n children: React.ReactNode;\n variants?: Variants;\n transition?: Transition;\n className?: string;\n defaultOpen?: boolean;\n onOpenChange?: (open: boolean) => void;\n open?: boolean;\n};\n\nfunction Dialog({\n children,\n variants = defaultVariants,\n transition = defaultTransition,\n defaultOpen,\n onOpenChange,\n open,\n}: DialogProps) {\n const [uncontrolledOpen, setUncontrolledOpen] = React.useState(\n defaultOpen || false\n );\n const dialogRef = useRef(null);\n const isOpen = open !== undefined ? open : uncontrolledOpen;\n\n // prevent scroll when dialog is open on iOS\n usePreventScroll({\n isDisabled: !isOpen,\n });\n\n const setIsOpen = React.useCallback(\n (value: boolean) => {\n setUncontrolledOpen(value);\n onOpenChange?.(value);\n },\n [onOpenChange]\n );\n\n useEffect(() => {\n const dialog = dialogRef.current;\n if (!dialog) return;\n\n if (isOpen) {\n document.body.classList.add('overflow-hidden');\n } else {\n document.body.classList.remove('overflow-hidden');\n }\n\n const handleCancel = (e: Event) => {\n e.preventDefault();\n if (isOpen) {\n setIsOpen(false);\n }\n };\n\n dialog.addEventListener('cancel', handleCancel);\n return () => {\n dialog.removeEventListener('cancel', handleCancel);\n document.body.classList.remove('overflow-hidden');\n };\n }, [dialogRef, isOpen, setIsOpen]);\n\n useEffect(() => {\n if (isOpen && dialogRef.current) {\n dialogRef.current.showModal();\n }\n }, [isOpen]);\n\n const handleTrigger = () => {\n setIsOpen(true);\n };\n\n const onAnimationComplete = (definition: string) => {\n if (definition === 'exit' && !isOpen) {\n dialogRef.current?.close();\n }\n };\n\n const baseId = useId();\n const ids = {\n dialog: `motion-ui-dialog-${baseId}`,\n title: `motion-ui-dialog-title-${baseId}`,\n description: `motion-ui-dialog-description-${baseId}`,\n };\n\n return (\n \n {children}\n \n );\n}\n\nexport type DialogTriggerProps = {\n children: React.ReactNode;\n className?: string;\n};\n\nfunction DialogTrigger({ children, className }: DialogTriggerProps) {\n const context = useContext(DialogContext);\n if (!context) throw new Error('DialogTrigger must be used within Dialog');\n\n return (\n \n {children}\n \n );\n}\n\nexport type DialogPortalProps = {\n children: React.ReactNode;\n container?: HTMLElement | null;\n};\n\nfunction DialogPortal({\n children,\n container = typeof window !== 'undefined' ? document.body : null,\n}: DialogPortalProps) {\n const [mounted, setMounted] = React.useState(false);\n const [portalContainer, setPortalContainer] =\n React.useState(null);\n\n useEffect(() => {\n setMounted(true);\n setPortalContainer(container || document.body);\n return () => setMounted(false);\n }, [container]);\n\n if (!mounted || !portalContainer) {\n return null;\n }\n\n return createPortal(children, portalContainer);\n}\nexport type DialogContentProps = {\n children: React.ReactNode;\n className?: string;\n container?: HTMLElement;\n};\n\nfunction DialogContent({ children, className, container }: DialogContentProps) {\n const context = useContext(DialogContext);\n if (!context) throw new Error('DialogContent must be used within Dialog');\n const {\n isOpen,\n setIsOpen,\n dialogRef,\n variants,\n transition,\n ids,\n onAnimationComplete,\n } = context;\n\n const content = (\n \n {isOpen && (\n }\n id={ids.dialog}\n aria-labelledby={ids.title}\n aria-describedby={ids.description}\n aria-modal='true'\n role='dialog'\n onClick={(e) => {\n if (e.target === dialogRef.current) {\n setIsOpen(false);\n }\n }}\n initial='initial'\n animate='animate'\n exit='exit'\n variants={variants}\n transition={transition}\n onAnimationComplete={onAnimationComplete}\n className={cn(\n 'fixed top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 transform rounded-lg border border-zinc-200 p-0 shadow-lg dark:border dark:border-zinc-700',\n 'backdrop:bg-black/50 backdrop:backdrop-blur-xs',\n 'open:flex open:flex-col',\n className\n )}\n >\n
{children}
\n \n )}\n
\n );\n\n return {content};\n}\n\nexport type DialogHeaderProps = {\n children: React.ReactNode;\n className?: string;\n};\n\nfunction DialogHeader({ children, className }: DialogHeaderProps) {\n return (\n
{children}
\n );\n}\n\nexport type DialogTitleProps = {\n children: React.ReactNode;\n className?: string;\n};\n\nfunction DialogTitle({ children, className }: DialogTitleProps) {\n const context = useContext(DialogContext);\n if (!context) throw new Error('DialogTitle must be used within Dialog');\n\n return (\n \n {children}\n \n );\n}\n\nexport type DialogDescriptionProps = {\n children: React.ReactNode;\n className?: string;\n};\n\nfunction DialogDescription({ children, className }: DialogDescriptionProps) {\n const context = useContext(DialogContext);\n if (!context) throw new Error('DialogDescription must be used within Dialog');\n\n return (\n \n {children}\n

\n );\n}\n\nexport type DialogCloseProps = {\n className?: string;\n children?: React.ReactNode;\n disabled?: boolean;\n};\n\nfunction DialogClose({ className, children, disabled }: DialogCloseProps) {\n const context = useContext(DialogContext);\n if (!context) throw new Error('DialogClose must be used within Dialog');\n\n return (\n context.setIsOpen(false)}\n type='button'\n aria-label='Close dialog'\n className={cn(\n 'absolute top-4 right-4 rounded-xs opacity-70 transition-opacity',\n 'hover:opacity-100 focus:ring-2 focus:outline-hidden',\n 'focus:ring-zinc-500 focus:ring-offset-2 disabled:pointer-events-none',\n className\n )}\n disabled={disabled}\n >\n {children || }\n Close\n \n );\n}\n\nexport {\n Dialog,\n DialogTrigger,\n DialogContent,\n DialogHeader,\n DialogTitle,\n DialogDescription,\n DialogClose,\n};\n", "type": "registry:ui" }, { "path": "hooks/usePreventScroll.tsx", "content": "// This code comes from https://github.com/adobe/react-spectrum/blob/main/packages/%40react-aria/overlays/src/usePreventScroll.ts\n\nimport { useEffect, useLayoutEffect } from 'react';\n\nfunction isMac(): boolean | undefined {\n return testPlatform(/^Mac/);\n}\n\nfunction isIPhone(): boolean | undefined {\n return testPlatform(/^iPhone/);\n}\n\nfunction isIPad(): boolean | undefined {\n return (\n testPlatform(/^iPad/) ||\n // iPadOS 13 lies and says it's a Mac, but we can distinguish by detecting touch support.\n (isMac() && navigator.maxTouchPoints > 1)\n );\n}\n\nfunction isIOS(): boolean | undefined {\n return isIPhone() || isIPad();\n}\n\nfunction testPlatform(re: RegExp): boolean | undefined {\n return typeof window !== 'undefined' && window.navigator != null\n ? re.test(window.navigator.platform)\n : undefined;\n}\n\nconst KEYBOARD_BUFFER = 24;\n\nexport const useIsomorphicLayoutEffect =\n typeof window !== 'undefined' ? useLayoutEffect : useEffect;\n\ninterface PreventScrollOptions {\n /** Whether the scroll lock is disabled. */\n isDisabled?: boolean;\n focusCallback?: () => void;\n}\n\nfunction chain(...callbacks: any[]): (...args: any[]) => void {\n return (...args: any[]) => {\n for (let callback of callbacks) {\n if (typeof callback === 'function') {\n callback(...args);\n }\n }\n };\n}\n\n// @ts-ignore\nconst visualViewport = typeof document !== 'undefined' && window.visualViewport;\n\nexport function isScrollable(node: Element): boolean {\n let style = window.getComputedStyle(node);\n return /(auto|scroll)/.test(\n style.overflow + style.overflowX + style.overflowY\n );\n}\n\nexport function getScrollParent(node: Element): Element {\n if (isScrollable(node)) {\n node = node.parentElement as HTMLElement;\n }\n\n while (node && !isScrollable(node)) {\n node = node.parentElement as HTMLElement;\n }\n\n return node || document.scrollingElement || document.documentElement;\n}\n\n// HTML input types that do not cause the software keyboard to appear.\nconst nonTextInputTypes = new Set([\n 'checkbox',\n 'radio',\n 'range',\n 'color',\n 'file',\n 'image',\n 'button',\n 'submit',\n 'reset',\n]);\n\n// The number of active usePreventScroll calls. Used to determine whether to revert back to the original page style/scroll position\nlet preventScrollCount = 0;\nlet restore: () => void;\n\n/**\n * Prevents scrolling on the document body on mount, and\n * restores it on unmount. Also ensures that content does not\n * shift due to the scrollbars disappearing.\n */\nexport function usePreventScroll(options: PreventScrollOptions = {}) {\n let { isDisabled } = options;\n\n useIsomorphicLayoutEffect(() => {\n if (isDisabled) {\n return;\n }\n\n preventScrollCount++;\n if (preventScrollCount === 1) {\n if (isIOS()) {\n restore = preventScrollMobileSafari();\n }\n }\n\n return () => {\n preventScrollCount--;\n if (preventScrollCount === 0) {\n restore?.();\n }\n };\n }, [isDisabled]);\n}\n\n// Mobile Safari is a whole different beast. Even with overflow: hidden,\n// it still scrolls the page in many situations:\n//\n// 1. When the bottom toolbar and address bar are collapsed, page scrolling is always allowed.\n// 2. When the keyboard is visible, the viewport does not resize. Instead, the keyboard covers part of\n// it, so it becomes scrollable.\n// 3. When tapping on an input, the page always scrolls so that the input is centered in the visual viewport.\n// This may cause even fixed position elements to scroll off the screen.\n// 4. When using the next/previous buttons in the keyboard to navigate between inputs, the whole page always\n// scrolls, even if the input is inside a nested scrollable element that could be scrolled instead.\n//\n// In order to work around these cases, and prevent scrolling without jankiness, we do a few things:\n//\n// 1. Prevent default on `touchmove` events that are not in a scrollable element. This prevents touch scrolling\n// on the window.\n// 2. Prevent default on `touchmove` events inside a scrollable element when the scroll position is at the\n// top or bottom. This avoids the whole page scrolling instead, but does prevent overscrolling.\n// 3. Prevent default on `touchend` events on input elements and handle focusing the element ourselves.\n// 4. When focusing an input, apply a transform to trick Safari into thinking the input is at the top\n// of the page, which prevents it from scrolling the page. After the input is focused, scroll the element\n// into view ourselves, without scrolling the whole page.\n// 5. Offset the body by the scroll position using a negative margin and scroll to the top. This should appear the\n// same visually, but makes the actual scroll position always zero. This is required to make all of the\n// above work or Safari will still try to scroll the page when focusing an input.\n// 6. As a last resort, handle window scroll events, and scroll back to the top. This can happen when attempting\n// to navigate to an input with the next/previous buttons that's outside a modal.\nfunction preventScrollMobileSafari() {\n let scrollable: Element;\n let lastY = 0;\n let onTouchStart = (e: TouchEvent) => {\n // Store the nearest scrollable parent element from the element that the user touched.\n scrollable = getScrollParent(e.target as Element);\n if (\n scrollable === document.documentElement &&\n scrollable === document.body\n ) {\n return;\n }\n\n lastY = e.changedTouches[0].pageY;\n };\n\n let onTouchMove = (e: TouchEvent) => {\n // Prevent scrolling the window.\n if (\n !scrollable ||\n scrollable === document.documentElement ||\n scrollable === document.body\n ) {\n e.preventDefault();\n return;\n }\n\n // Prevent scrolling up when at the top and scrolling down when at the bottom\n // of a nested scrollable area, otherwise mobile Safari will start scrolling\n // the window instead. Unfortunately, this disables bounce scrolling when at\n // the top but it's the best we can do.\n let y = e.changedTouches[0].pageY;\n let scrollTop = scrollable.scrollTop;\n let bottom = scrollable.scrollHeight - scrollable.clientHeight;\n\n if (bottom === 0) {\n return;\n }\n\n if ((scrollTop <= 0 && y > lastY) || (scrollTop >= bottom && y < lastY)) {\n e.preventDefault();\n }\n\n lastY = y;\n };\n\n let onTouchEnd = (e: TouchEvent) => {\n let target = e.target as HTMLElement;\n\n // Apply this change if we're not already focused on the target element\n if (isInput(target) && target !== document.activeElement) {\n e.preventDefault();\n\n // Apply a transform to trick Safari into thinking the input is at the top of the page\n // so it doesn't try to scroll it into view. When tapping on an input, this needs to\n // be done before the \"focus\" event, so we have to focus the element ourselves.\n target.style.transform = 'translateY(-2000px)';\n target.focus();\n requestAnimationFrame(() => {\n target.style.transform = '';\n });\n }\n };\n\n let onFocus = (e: FocusEvent) => {\n let target = e.target as HTMLElement;\n if (isInput(target)) {\n // Transform also needs to be applied in the focus event in cases where focus moves\n // other than tapping on an input directly, e.g. the next/previous buttons in the\n // software keyboard. In these cases, it seems applying the transform in the focus event\n // is good enough, whereas when tapping an input, it must be done before the focus event. 🤷‍♂️\n target.style.transform = 'translateY(-2000px)';\n requestAnimationFrame(() => {\n target.style.transform = '';\n\n // This will have prevented the browser from scrolling the focused element into view,\n // so we need to do this ourselves in a way that doesn't cause the whole page to scroll.\n if (visualViewport) {\n if (visualViewport.height < window.innerHeight) {\n // If the keyboard is already visible, do this after one additional frame\n // to wait for the transform to be removed.\n requestAnimationFrame(() => {\n scrollIntoView(target);\n });\n } else {\n // Otherwise, wait for the visual viewport to resize before scrolling so we can\n // measure the correct position to scroll to.\n visualViewport.addEventListener(\n 'resize',\n () => scrollIntoView(target),\n { once: true }\n );\n }\n }\n });\n }\n };\n\n let onWindowScroll = () => {\n // Last resort. If the window scrolled, scroll it back to the top.\n // It should always be at the top because the body will have a negative margin (see below).\n window.scrollTo(0, 0);\n };\n\n // Record the original scroll position so we can restore it.\n // Then apply a negative margin to the body to offset it by the scroll position. This will\n // enable us to scroll the window to the top, which is required for the rest of this to work.\n let scrollX = window.pageXOffset;\n let scrollY = window.pageYOffset;\n\n let restoreStyles = chain(\n setStyle(\n document.documentElement,\n 'paddingRight',\n `${window.innerWidth - document.documentElement.clientWidth}px`\n )\n // setStyle(document.documentElement, 'overflow', 'hidden'),\n // setStyle(document.body, 'marginTop', `-${scrollY}px`),\n );\n\n // Scroll to the top. The negative margin on the body will make this appear the same.\n window.scrollTo(0, 0);\n\n let removeEvents = chain(\n addEvent(document, 'touchstart', onTouchStart, {\n passive: false,\n capture: true,\n }),\n addEvent(document, 'touchmove', onTouchMove, {\n passive: false,\n capture: true,\n }),\n addEvent(document, 'touchend', onTouchEnd, {\n passive: false,\n capture: true,\n }),\n addEvent(document, 'focus', onFocus, true),\n addEvent(window, 'scroll', onWindowScroll)\n );\n\n return () => {\n // Restore styles and scroll the page back to where it was.\n restoreStyles();\n removeEvents();\n window.scrollTo(scrollX, scrollY);\n };\n}\n\n// Sets a CSS property on an element, and returns a function to revert it to the previous value.\nfunction setStyle(\n element: HTMLElement,\n style: keyof React.CSSProperties,\n value: string\n) {\n // https://github.com/microsoft/TypeScript/issues/17827#issuecomment-391663310\n // @ts-ignore\n let cur = element.style[style];\n // @ts-ignore\n element.style[style] = value;\n\n return () => {\n // @ts-ignore\n element.style[style] = cur;\n };\n}\n\n// Adds an event listener to an element, and returns a function to remove it.\nfunction addEvent(\n target: EventTarget,\n event: K,\n handler: (this: Document, ev: GlobalEventHandlersEventMap[K]) => any,\n options?: boolean | AddEventListenerOptions\n) {\n // @ts-ignore\n target.addEventListener(event, handler, options);\n\n return () => {\n // @ts-ignore\n target.removeEventListener(event, handler, options);\n };\n}\n\nfunction scrollIntoView(target: Element) {\n let root = document.scrollingElement || document.documentElement;\n while (target && target !== root) {\n // Find the parent scrollable element and adjust the scroll position if the target is not already in view.\n let scrollable = getScrollParent(target);\n if (\n scrollable !== document.documentElement &&\n scrollable !== document.body &&\n scrollable !== target\n ) {\n let scrollableTop = scrollable.getBoundingClientRect().top;\n let targetTop = target.getBoundingClientRect().top;\n let targetBottom = target.getBoundingClientRect().bottom;\n // Buffer is needed for some edge cases\n const keyboardHeight =\n scrollable.getBoundingClientRect().bottom + KEYBOARD_BUFFER;\n\n if (targetBottom > keyboardHeight) {\n scrollable.scrollTop += targetTop - scrollableTop;\n }\n }\n\n // @ts-ignore\n target = scrollable.parentElement;\n }\n}\n\nexport function isInput(target: Element) {\n return (\n (target instanceof HTMLInputElement &&\n !nonTextInputTypes.has(target.type)) ||\n target instanceof HTMLTextAreaElement ||\n (target instanceof HTMLElement && target.isContentEditable)\n );\n}\n", "type": "registry:hook" } ] }