"use client" import * as React from "react" import { useEffect, useState } from "react" import { RemoveScroll } from "react-remove-scroll" import { XmarkIcon } from "../icons-v2-generated" import { cn } from "../../utils/cn" // Duration of the open/close animation in ms — keep in sync with the // `duration-200` utilities applied to the backdrop and panel below. const ANIMATION_DURATION = 200 const ModalContext = React.createContext<{ onClose?: () => void }>({}) /** * Panel width, as a named variant rather than a per-call-site `max-w-*`. * `medium` is the single-column form width; `wide` is the two-column editor * width and pairs with `ModalV2TwoColumn`. * * Every arm keeps the `calc(100vw-2rem)` arm so content can never stretch the * panel past the viewport (minus the `mx-4` margins) on narrow screens. */ export type ModalV2Size = "default" | "medium" | "wide" const MODAL_V2_SIZE_CLASSES: Record = { default: "max-w-[min(28rem,calc(100vw-2rem))]", medium: "max-w-[min(42rem,calc(100vw-2rem))]", wide: "max-w-[min(1400px,calc(100vw-2rem))]", } interface ModalProps { isOpen: boolean onClose: () => void children: React.ReactNode className?: string size?: ModalV2Size } interface ModalContentProps { children: React.ReactNode className?: string } interface ModalHeaderProps { children: React.ReactNode className?: string } interface ModalTitleProps { children: React.ReactNode className?: string } interface ModalFooterProps { children: React.ReactNode className?: string } interface ModalTwoColumnProps { left: React.ReactNode right: React.ReactNode className?: string } // Topmost-modal stack: with stacked modals (confirm above a form) only the // TOP one may contain focus, or they fight each other. const modalStack: symbol[] = [] // Radix popups (Select/DropdownMenu/Popover content) PORTAL to , so a // dropdown opened from inside the modal focuses nodes OUTSIDE the panel. // Focus landing there is NOT an escape — yanking it back (containFocus / // reasserts / Tab trap) fights Radix's own item-focus management and strands // stale `data-highlighted` on items it never got to blur-clean (observed: // two or three select options painted "hovered" simultaneously). Popper-based // layers carry `data-radix-popper-content-wrapper`; the default item-aligned // Select content doesn't, hence the role fallbacks. const isInPortaledLayer = (node: Node | null): boolean => node instanceof Element && node.closest( '[data-radix-popper-content-wrapper], [role="listbox"], [role="menu"]', ) !== null const Modal = React.forwardRef( ({ isOpen, onClose, children, className, size = "default" }, ref) => { // Keep the modal mounted while the exit animation plays. const [isMounted, setIsMounted] = useState(isOpen) const panelRef = React.useRef(null) const restoreFocusRef = React.useRef(null) const stackIdRef = React.useRef(Symbol('modal')) // FOCUS MANAGEMENT — the dialog contract every ModalV2 consumer was // missing: focus moves INTO the dialog on open (a modal opened from a // Radix dropdown otherwise loses the focus race to the menu's // close-restore and focus stays on the page), Tab cycles inside it, and // focus returns to the opener on close. useEffect(() => { if (!isOpen) return const stackId = stackIdRef.current modalStack.push(stackId) restoreFocusRef.current = document.activeElement as HTMLElement | null const raf = requestAnimationFrame(() => { const panel = panelRef.current if (!panel) return // If a consumer already moved focus inside (e.g. a form autofocusing // its first field), leave it alone. if (panel.contains(document.activeElement)) return panel.focus() }) // CONTAINMENT, not a one-shot: whatever tears down after the dialog // opens (a Radix menu restoring/abandoning focus frames later, a // removed node dropping focus to body) — if focus leaves the topmost // dialog, pull it back. This is what actually wins the multi-frame // focus races a single autofocus cannot. const containFocus = (event: FocusEvent) => { if (modalStack[modalStack.length - 1] !== stackId) return const panel = panelRef.current if (!panel) return const target = event.target as Node | null if (target && (panel.contains(target) || isInPortaledLayer(target))) return panel.focus() } document.addEventListener('focusin', containFocus) // Focus dropped to by NODE REMOVAL (a closing menu unmounting the // focused item) dispatches NO focusin — bounded re-asserts cover it. const reasserts = [80, 240, 500].map(ms => setTimeout(() => { if (modalStack[modalStack.length - 1] !== stackId) return const panel = panelRef.current if (!panel) return if ( !panel.contains(document.activeElement) && !isInPortaledLayer(document.activeElement) ) panel.focus() }, ms), ) return () => { cancelAnimationFrame(raf) reasserts.forEach(clearTimeout) document.removeEventListener('focusin', containFocus) const idx = modalStack.lastIndexOf(stackId) if (idx !== -1) modalStack.splice(idx, 1) restoreFocusRef.current?.focus?.() } }, [isOpen]) // Tab trap: cycle within the panel's focusables. useEffect(() => { if (!isOpen) return const handleTab = (event: KeyboardEvent) => { if (event.key !== 'Tab') return const panel = panelRef.current if (!panel) return // Focus inside a portaled Radix layer (open Select/menu): Tab is that // layer's affair — trapping it back into the panel closes nothing and // steals focus mid-interaction. if (isInPortaledLayer(document.activeElement)) return const focusables = Array.from( panel.querySelectorAll( 'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])', ), ).filter(el => el.getAttribute('aria-hidden') !== 'true' && el.offsetParent !== null) if (focusables.length === 0) return const first = focusables[0] const last = focusables[focusables.length - 1] const active = document.activeElement if (event.shiftKey) { if (active === first || !panel.contains(active)) { event.preventDefault() last.focus() } } else if (active === last || !panel.contains(active)) { event.preventDefault() first.focus() } } document.addEventListener('keydown', handleTab) return () => document.removeEventListener('keydown', handleTab) }, [isOpen]) useEffect(() => { if (isOpen) { setIsMounted(true) return } const timeout = setTimeout(() => setIsMounted(false), ANIMATION_DURATION) return () => clearTimeout(timeout) }, [isOpen]) // Scroll lock via the SAME library Radix primitives use internally // (react-remove-scroll is ref-counted and composes with itself). The // previous react-aria lock FOUGHT the lock a Radix Select opened inside // the modal added on top — body jumped and the page behind went black // while the select was open. // Escape key (document-level: top-of-stack semantics for modals) useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { // A nested Radix layer (Select, DropdownMenu) preventDefaults the // Escape it consumes — without this check, closing a select inside // the modal closed the WHOLE modal. The stack check keeps Escape a // topmost-only affair: every open modal registers this listener, so // with stacked modals (confirm above a form) one Escape would // otherwise close BOTH. if ( event.key === 'Escape' && !event.defaultPrevented && modalStack[modalStack.length - 1] === stackIdRef.current ) { onClose() } } if (isOpen) { document.addEventListener('keydown', handleKeyDown) return () => document.removeEventListener('keydown', handleKeyDown) } }, [isOpen, onClose]) if (!isMounted) return null const state = isOpen ? "open" : "closed" return ( // `forwardProps` — RemoveScroll's default is to WRAP its children in a // plain
. That wrapper is in normal flow, so a modal rendered inside // a `flex`/`grid` container with a `gap` became an extra (zero-height) // track the moment it opened, shifting every sibling below it down by one // gap. Cloning onto the child instead leaves only the `fixed` overlay, // which is out of flow and therefore not a flex/grid item at all. // The child must stay a single element for `Children.only`. {/* SOFTWARE KEYBOARD — padding, not a shrunken box. Neither mobile shell shrinks the LAYOUT viewport when the keyboard opens (WKWebView keeps its frame; Android's window is edge-to-edge so the IME arrives as a WindowInsets type), so `inset-0` and every vh/% height keep reporting the full screen and this bottom-anchored panel opened entirely BEHIND the keyboard. Padding leaves the backdrop full-screen while the flex CONTENT box — which is what `items-end` anchors to and what the panel's percentage max-height resolves against — stops above it. `--of-keyboard-inset` is published by the app (keyboard-inset.ts); unset everywhere else, hence the 0px fallback. The 200ms below is mirrored there as INSET_TRANSITION_MS — it delays the app's scroll-the-focused-field re-assert until this transition settles. */}
) } ) Modal.displayName = "ModalV2" const ModalContent = React.forwardRef( ({ children, className }, ref) => (
{children}
) ) ModalContent.displayName = "ModalV2Content" /** * The two-column editor body. Use INSTEAD OF `ModalV2Content` (not inside it) * on a `size="wide"` modal — it owns the same `flex-1 min-h-0` slot. * * Scroll model, which is the whole reason this is a component and not a pair * of copy-pasted class strings: below `lg` there is ONE column and the outer * region scrolls, so a phone behaves exactly like every single-column modal. * From `lg` up the outer region is `overflow-hidden` and each column scrolls * independently, so a long left column never drags the right one out of view. */ const ModalTwoColumn = React.forwardRef( ({ left, right, className }, ref) => (
{/* `lg:grid-rows-[minmax(0,1fr)]` is what makes the columns scroll: an implicit `auto` row sizes to the TALLER column, so the columns had no bounded height to scroll inside and the outer `overflow-hidden` silently clipped them instead. minmax(0,…) also beats the default `min-height:auto` on the row, which would otherwise refuse to shrink below content height. */}
{left}
{right}
) ) ModalTwoColumn.displayName = "ModalV2TwoColumn" const ModalHeader = React.forwardRef( ({ children, className }, ref) => { const { onClose } = React.useContext(ModalContext) return (
{children}
{onClose && ( )}
) } ) ModalHeader.displayName = "ModalV2Header" const ModalTitle = React.forwardRef( ({ children, className }, ref) => (

{children}

) ) ModalTitle.displayName = "ModalV2Title" const ModalFooter = React.forwardRef( ({ children, className }, ref) => (
{children}
) ) ModalFooter.displayName = "ModalV2Footer" export { Modal as ModalV2, ModalContent as ModalV2Content, ModalFooter as ModalV2Footer, ModalHeader as ModalV2Header, ModalTitle as ModalV2Title, ModalTwoColumn as ModalV2TwoColumn }