From b270b4566c3d53b067953eb57e5248f88092ddf7 Mon Sep 17 00:00:00 2001 From: Dilshod Turobov Date: Sun, 27 Sep 2026 04:49:32 +0200 Subject: [PATCH 1/2] Overlays inside other libraries' modals, and in RTL pages Popovers (Select, ComboBox, MultiSelect, Dropdown, DatePicker, DateRangePicker, nav account menus) and modals (Modal, SlideoutMenu) opened from inside another library's modal layer (Radix/Base UI: shadcn/ui Dialog, Sheet, AlertDialog, Drawer) portalled to document.body, where Radix's pointer-events: none, focus trap and document-level Escape made them unusable. They now render into that layer (utils/overlay-host.tsx, React Aria's UNSTABLE_portalContainer); full-screen overlays are offset back over the viewport when the layer is transformed, and Escape in them closes only them. Menus of other libraries opened from our Modal or SlideoutMenu are marked as React Aria top layers so they stay clickable and focusable. Without a foreign layer nothing changes: same portal, same DOM. React Aria takes direction from its locale, not the page: when the page sets dir and the browser-default locale disagrees (no I18nProvider), popovers get a locale for the page's direction (the page's lang when it matches). Physical popover placements become logical (bottom end, end top), identical in LTR. --- .../base-components/nav-account-card.tsx | 7 +- .../sidebar-navigation/sidebar-slim.tsx | 7 +- .../application/date-picker/date-picker.tsx | 9 +- .../date-picker/date-range-picker.tsx | 9 +- components/application/modals/modal.tsx | 44 ++- .../slideout-menus/slideout-menu.tsx | 38 ++- .../dropdown/dropdown-account-breadcrumb.tsx | 2 +- .../base/dropdown/dropdown-account-button.tsx | 2 +- .../dropdown/dropdown-account-card-md.tsx | 4 +- .../dropdown/dropdown-account-card-sm.tsx | 4 +- .../dropdown/dropdown-account-card-xs.tsx | 2 +- components/base/dropdown/dropdown-avatar.tsx | 2 +- .../dropdown/dropdown-button-advanced.tsx | 6 +- .../base/dropdown/dropdown-button-simple.tsx | 2 +- .../dropdown-context-menu-advanced.tsx | 8 +- .../dropdown/dropdown-context-menu-simple.tsx | 4 +- .../base/dropdown/dropdown-icon-advanced.tsx | 6 +- .../base/dropdown/dropdown-icon-simple.tsx | 2 +- .../dropdown/dropdown-search-advanced.tsx | 2 +- components/base/dropdown/dropdown.tsx | 8 +- components/base/select/multi-select.tsx | 6 +- components/base/select/popover.tsx | 4 +- utils/overlay-host.tsx | 280 ++++++++++++++++++ 23 files changed, 383 insertions(+), 75 deletions(-) create mode 100644 utils/overlay-host.tsx diff --git a/components/application/app-navigation/base-components/nav-account-card.tsx b/components/application/app-navigation/base-components/nav-account-card.tsx index f053695..1a0c90c 100644 --- a/components/application/app-navigation/base-components/nav-account-card.tsx +++ b/components/application/app-navigation/base-components/nav-account-card.tsx @@ -5,12 +5,13 @@ import { useCallback, useEffect, useRef } from "react"; import { BookOpen01, ChevronSelectorVertical, LogOut01, Plus, Settings01, User01 } from "@untitledui/icons"; import { useFocusManager } from "react-aria"; import type { DialogProps as AriaDialogProps, Placement as AriaPlacement } from "react-aria-components"; -import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components"; +import { Button as AriaButton, Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger } from "react-aria-components"; import { AvatarLabelGroup } from "@/components/base/avatar/avatar-label-group"; import { Button } from "@/components/base/buttons/button"; import { RadioButtonBase } from "@/components/base/radio-buttons/radio-buttons"; import { useBreakpoint } from "@/hooks/use-breakpoint"; import { cx } from "@/utils/cx"; +import { OverlayPopover } from "@/utils/overlay-host"; export type NavAccountType = { /** Unique identifier for the nav item. */ @@ -189,7 +190,7 @@ export const NavAccountCard = ({ - - + ); diff --git a/components/application/app-navigation/sidebar-navigation/sidebar-slim.tsx b/components/application/app-navigation/sidebar-navigation/sidebar-slim.tsx index 1c190df..7be0988 100644 --- a/components/application/app-navigation/sidebar-navigation/sidebar-slim.tsx +++ b/components/application/app-navigation/sidebar-navigation/sidebar-slim.tsx @@ -4,12 +4,13 @@ import type { FC } from "react"; import { useState } from "react"; import { DotsVertical, LifeBuoy01, Settings01 } from "@untitledui/icons"; import { AnimatePresence, motion } from "motion/react"; -import { Button as AriaButton, DialogTrigger as AriaDialogTrigger, Popover as AriaPopover } from "react-aria-components"; +import { Button as AriaButton, DialogTrigger as AriaDialogTrigger } from "react-aria-components"; import { Avatar } from "@/components/base/avatar/avatar"; import { ButtonUtility } from "@/components/base/buttons/button-utility"; import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo"; import { UntitledLogoMinimal } from "@/components/foundations/logo/untitledui-logo-minimal"; import { cx } from "@/utils/cx"; +import { OverlayPopover } from "@/utils/overlay-host"; import { MobileNavigationHeader } from "../base-components/mobile-header"; import { NavAccountCard, NavAccountMenu } from "../base-components/nav-account-card"; import { NavButton } from "../base-components/nav-button"; @@ -104,7 +105,7 @@ export const SidebarNavigationSlim = ({ activeUrl, items, footerItems = [], hide alt="Olivia Rhye" /> - - + diff --git a/components/application/date-picker/date-picker.tsx b/components/application/date-picker/date-picker.tsx index 8af7e2a..e7138cd 100644 --- a/components/application/date-picker/date-picker.tsx +++ b/components/application/date-picker/date-picker.tsx @@ -5,9 +5,10 @@ import { useControlledState } from "@react-stately/utils"; import { Calendar as CalendarIcon } from "@untitledui/icons"; import { useDateFormatter } from "react-aria"; import type { DatePickerProps as AriaDatePickerProps, DateValue } from "react-aria-components"; -import { DatePicker as AriaDatePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover } from "react-aria-components"; +import { DatePicker as AriaDatePicker, Dialog as AriaDialog, Group as AriaGroup } from "react-aria-components"; import { Button, type ButtonProps } from "@/components/base/buttons/button"; import { cx } from "@/utils/cx"; +import { OverlayPopover } from "@/utils/overlay-host"; import { Calendar } from "./calendar"; const highlightedDates = [today(getLocalTimeZone())]; @@ -37,9 +38,9 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, {formattedDate} - cx( "origin-(--trigger-anchor-point) will-change-transform", @@ -81,7 +82,7 @@ export const DatePicker = ({ value: valueProp, defaultValue, onChange, onApply, )} - + ); }; diff --git a/components/application/date-picker/date-range-picker.tsx b/components/application/date-picker/date-range-picker.tsx index df59e78..47c192f 100644 --- a/components/application/date-picker/date-range-picker.tsx +++ b/components/application/date-picker/date-range-picker.tsx @@ -6,10 +6,11 @@ import { useControlledState } from "@react-stately/utils"; import { Calendar as CalendarIcon } from "@untitledui/icons"; import { useDateFormatter } from "react-aria"; import type { DateRangePickerProps as AriaDateRangePickerProps, DateValue } from "react-aria-components"; -import { DateRangePicker as AriaDateRangePicker, Dialog as AriaDialog, Group as AriaGroup, Popover as AriaPopover, useLocale } from "react-aria-components"; +import { DateRangePicker as AriaDateRangePicker, Dialog as AriaDialog, Group as AriaGroup, useLocale } from "react-aria-components"; import { Button, type ButtonProps } from "@/components/base/buttons/button"; import { InputDateBase } from "@/components/base/input/input-date"; import { cx } from "@/utils/cx"; +import { OverlayPopover } from "@/utils/overlay-host"; import { RangeCalendar, RangePresetButton } from "./range-calendar"; const now = today(getLocalTimeZone()); @@ -83,8 +84,8 @@ export const DateRangePicker = ({ value: valueProp, defaultValue, onChange, onAp {!value ? Select dates : `${formattedStartDate} – ${formattedEndDate}`} - cx( @@ -157,7 +158,7 @@ export const DateRangePicker = ({ value: valueProp, defaultValue, onChange, onAp )} - + ); }; diff --git a/components/application/modals/modal.tsx b/components/application/modals/modal.tsx index 93c253d..d2be93e 100644 --- a/components/application/modals/modal.tsx +++ b/components/application/modals/modal.tsx @@ -3,13 +3,20 @@ import type { DialogProps as AriaDialogProps, ModalOverlayProps as AriaModalOverlayProps } from "react-aria-components"; import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components"; import { cx } from "@/utils/cx"; +import { IN_HOST_LAYER, useFocusedHostLayer, useForeignOverlaysOnTop, withHostLayerOffset } from "@/utils/overlay-host"; export const DialogTrigger = AriaDialogTrigger; export const ModalOverlay = (props: AriaModalOverlayProps) => { + // Inside another library's modal layer (e.g. a shadcn/ui Dialog), render into it. + const host = useFocusedHostLayer(); + return ( cx( "fixed inset-0 z-50 flex min-h-dvh w-full items-end justify-center bg-overlay/70 px-4 outline-hidden backdrop-blur-[6px] sm:items-center sm:justify-center sm:px-8", @@ -25,22 +32,27 @@ export const ModalOverlay = (props: AriaModalOverlayProps) => { ); }; -export const Modal = (props: AriaModalOverlayProps) => ( - - cx( - "w-full rounded-xl bg-primary align-middle shadow-xl outline-hidden max-sm:overflow-y-auto sm:rounded-2xl", - // Max height based on parent's vertical padding - "max-h-[calc(var(--visual-viewport-height)-var(--modal-pt)-var(--modal-pb))]", - // Animations - state.isEntering && "duration-300 ease-out animate-in zoom-in-95", - state.isExiting && "duration-200 ease-in animate-out zoom-out-95", - typeof props.className === "function" ? props.className(state) : props.className, - ) - } - /> -); +export const Modal = (props: AriaModalOverlayProps) => { + // Keeps menus and popovers of other libraries opened from inside the modal usable. + useForeignOverlaysOnTop(); + + return ( + + cx( + "w-full rounded-xl bg-primary align-middle shadow-xl outline-hidden max-sm:overflow-y-auto sm:rounded-2xl", + // Max height based on parent's vertical padding + "max-h-[calc(var(--visual-viewport-height)-var(--modal-pt)-var(--modal-pb))]", + // Animations + state.isEntering && "duration-300 ease-out animate-in zoom-in-95", + state.isExiting && "duration-200 ease-in animate-out zoom-out-95", + typeof props.className === "function" ? props.className(state) : props.className, + ) + } + /> + ); +}; export const Dialog = (props: AriaDialogProps) => ( diff --git a/components/application/slideout-menus/slideout-menu.tsx b/components/application/slideout-menus/slideout-menu.tsx index fefa106..1a2b274 100644 --- a/components/application/slideout-menus/slideout-menu.tsx +++ b/components/application/slideout-menus/slideout-menu.tsx @@ -9,13 +9,20 @@ import type { import { Dialog as AriaDialog, DialogTrigger as AriaDialogTrigger, Modal as AriaModal, ModalOverlay as AriaModalOverlay } from "react-aria-components"; import { CloseButton } from "@/components/base/buttons/close-button"; import { cx } from "@/utils/cx"; +import { IN_HOST_LAYER, useFocusedHostLayer, useForeignOverlaysOnTop, withHostLayerOffset } from "@/utils/overlay-host"; interface ModalOverlayProps extends AriaModalOverlayProps, RefAttributes {} export const ModalOverlay = (props: ModalOverlayProps) => { + // Inside another library's modal layer (e.g. a shadcn/ui Sheet), render into it. + const host = useFocusedHostLayer(); + return ( cx( "fixed inset-0 flex min-h-dvh w-full items-center justify-end bg-overlay/70 pl-6 outline-hidden ease-linear md:pl-10", @@ -31,19 +38,24 @@ ModalOverlay.displayName = "ModalOverlay"; interface ModalProps extends AriaModalOverlayProps, RefAttributes {} -export const Modal = (props: ModalProps) => ( - - cx( - "inset-y-0 right-0 h-full w-full max-w-100 shadow-xl transition", - state.isEntering && "duration-300 animate-in slide-in-from-right", - state.isExiting && "duration-500 animate-out slide-out-to-right", - typeof props.className === "function" ? props.className(state) : props.className, - ) - } - /> -); +export const Modal = (props: ModalProps) => { + // Keeps menus and popovers of other libraries opened from inside the slideout usable. + useForeignOverlaysOnTop(); + + return ( + + cx( + "inset-y-0 right-0 h-full w-full max-w-100 shadow-xl transition", + state.isEntering && "duration-300 animate-in slide-in-from-right", + state.isExiting && "duration-500 animate-out slide-out-to-right", + typeof props.className === "function" ? props.className(state) : props.className, + ) + } + /> + ); +}; Modal.displayName = "Modal"; interface DialogProps extends AriaDialogProps, RefAttributes {} diff --git a/components/base/dropdown/dropdown-account-breadcrumb.tsx b/components/base/dropdown/dropdown-account-breadcrumb.tsx index d7117a1..12b9f70 100644 --- a/components/base/dropdown/dropdown-account-breadcrumb.tsx +++ b/components/base/dropdown/dropdown-account-breadcrumb.tsx @@ -48,7 +48,7 @@ export const DropdownAccountBreadcrumb = () => { - + { Support - + Help center Contact support diff --git a/components/base/dropdown/dropdown-account-card-md.tsx b/components/base/dropdown/dropdown-account-card-md.tsx index 0137a8f..7f59fe8 100644 --- a/components/base/dropdown/dropdown-account-card-md.tsx +++ b/components/base/dropdown/dropdown-account-card-md.tsx @@ -56,7 +56,7 @@ export const DropdownAccountCardMD = () => { Support - + Help center Contact support @@ -81,7 +81,7 @@ export const DropdownAccountCardMD = () => { Sign out - + Current device All devices diff --git a/components/base/dropdown/dropdown-account-card-sm.tsx b/components/base/dropdown/dropdown-account-card-sm.tsx index 790f6ce..f47d5dd 100644 --- a/components/base/dropdown/dropdown-account-card-sm.tsx +++ b/components/base/dropdown/dropdown-account-card-sm.tsx @@ -52,7 +52,7 @@ export const DropdownAccountCardSM = () => { Support - + Help center Contact support @@ -83,7 +83,7 @@ export const DropdownAccountCardSM = () => { Sign out - + Current device All devices diff --git a/components/base/dropdown/dropdown-account-card-xs.tsx b/components/base/dropdown/dropdown-account-card-xs.tsx index 47e3bec..dc628bd 100644 --- a/components/base/dropdown/dropdown-account-card-xs.tsx +++ b/components/base/dropdown/dropdown-account-card-xs.tsx @@ -64,7 +64,7 @@ export const DropdownAccountCardXS = () => { Sign out - + Current device All devices diff --git a/components/base/dropdown/dropdown-avatar.tsx b/components/base/dropdown/dropdown-avatar.tsx index cba33f8..0fe13bf 100644 --- a/components/base/dropdown/dropdown-avatar.tsx +++ b/components/base/dropdown/dropdown-avatar.tsx @@ -58,7 +58,7 @@ export const DropdownAvatar = () => { Support - + Help center Contact support diff --git a/components/base/dropdown/dropdown-button-advanced.tsx b/components/base/dropdown/dropdown-button-advanced.tsx index 0c5689c..0b66157 100644 --- a/components/base/dropdown/dropdown-button-advanced.tsx +++ b/components/base/dropdown/dropdown-button-advanced.tsx @@ -75,11 +75,11 @@ export const DropdownButtonAdvanced = () => { More tools - + Save as - + PDF HTML @@ -98,7 +98,7 @@ export const DropdownButtonAdvanced = () => { Developer - + View source Developer tools diff --git a/components/base/dropdown/dropdown-button-simple.tsx b/components/base/dropdown/dropdown-button-simple.tsx index d08aad8..7b6e2ea 100644 --- a/components/base/dropdown/dropdown-button-simple.tsx +++ b/components/base/dropdown/dropdown-button-simple.tsx @@ -28,7 +28,7 @@ export const DropdownButtonSimple = () => ( View details - + Share Save as diff --git a/components/base/dropdown/dropdown-context-menu-advanced.tsx b/components/base/dropdown/dropdown-context-menu-advanced.tsx index 1399cec..b44feb0 100644 --- a/components/base/dropdown/dropdown-context-menu-advanced.tsx +++ b/components/base/dropdown/dropdown-context-menu-advanced.tsx @@ -25,7 +25,7 @@ export const DropdownContextMenuAdvanced = () => { Right-click anywhere in this area - + Back @@ -60,11 +60,11 @@ export const DropdownContextMenuAdvanced = () => { More tools - + Save as - + PDF HTML @@ -83,7 +83,7 @@ export const DropdownContextMenuAdvanced = () => { Developer - + View source Developer tools diff --git a/components/base/dropdown/dropdown-context-menu-simple.tsx b/components/base/dropdown/dropdown-context-menu-simple.tsx index 0b68d84..0fb0216 100644 --- a/components/base/dropdown/dropdown-context-menu-simple.tsx +++ b/components/base/dropdown/dropdown-context-menu-simple.tsx @@ -12,7 +12,7 @@ export const DropdownContextMenuSimple = () => ( Right-click anywhere in this area - + Cut @@ -29,7 +29,7 @@ export const DropdownContextMenuSimple = () => ( View details - + Share Save as diff --git a/components/base/dropdown/dropdown-icon-advanced.tsx b/components/base/dropdown/dropdown-icon-advanced.tsx index 153b6ce..a1464d8 100644 --- a/components/base/dropdown/dropdown-icon-advanced.tsx +++ b/components/base/dropdown/dropdown-icon-advanced.tsx @@ -56,11 +56,11 @@ export const DropdownIconAdvanced = () => { More tools - + Save as - + PDF HTML @@ -79,7 +79,7 @@ export const DropdownIconAdvanced = () => { Developer - + View source Developer tools diff --git a/components/base/dropdown/dropdown-icon-simple.tsx b/components/base/dropdown/dropdown-icon-simple.tsx index ae54210..5f3b99a 100644 --- a/components/base/dropdown/dropdown-icon-simple.tsx +++ b/components/base/dropdown/dropdown-icon-simple.tsx @@ -24,7 +24,7 @@ export const DropdownIconSimple = () => ( View details - + Share Save as diff --git a/components/base/dropdown/dropdown-search-advanced.tsx b/components/base/dropdown/dropdown-search-advanced.tsx index 255e359..d83207d 100644 --- a/components/base/dropdown/dropdown-search-advanced.tsx +++ b/components/base/dropdown/dropdown-search-advanced.tsx @@ -33,7 +33,7 @@ export const DropdownSearchAdvanced = () => { Untitled UI - + { return ( - cx( @@ -155,7 +155,7 @@ const DropdownPopover = (props: DropdownPopoverProps) => { } > {props.children} - + ); }; diff --git a/components/base/select/multi-select.tsx b/components/base/select/multi-select.tsx index ece4a73..6f6071b 100644 --- a/components/base/select/multi-select.tsx +++ b/components/base/select/multi-select.tsx @@ -12,7 +12,6 @@ import { DialogTrigger as AriaDialogTrigger, Input as AriaInput, ListBox as AriaListBox, - Popover as AriaPopover, SearchField as AriaSearchField, } from "react-aria-components"; import { Button } from "@/components/base/buttons/button"; @@ -21,6 +20,7 @@ import { Label } from "@/components/base/input/label"; import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon"; import { cx } from "@/utils/cx"; import { isReactComponent } from "@/utils/is-react-component"; +import { OverlayPopover } from "@/utils/overlay-host"; import { SelectItem } from "./select-item"; import { type CommonProps, SelectContext, type SelectItemType, sizes } from "./select-shared"; @@ -255,7 +255,7 @@ const MultiSelectRoot = ({ - } - + {hint && ( diff --git a/components/base/select/popover.tsx b/components/base/select/popover.tsx index e85d3c0..31b1b89 100644 --- a/components/base/select/popover.tsx +++ b/components/base/select/popover.tsx @@ -2,8 +2,8 @@ import type { RefAttributes } from "react"; import type { PopoverProps as AriaPopoverProps } from "react-aria-components"; -import { Popover as AriaPopover } from "react-aria-components"; import { cx } from "@/utils/cx"; +import { OverlayPopover } from "@/utils/overlay-host"; interface PopoverProps extends AriaPopoverProps, RefAttributes { size: "sm" | "md" | "lg"; @@ -11,7 +11,7 @@ interface PopoverProps extends AriaPopoverProps, RefAttributes { export const Popover = (props: PopoverProps) => { return ( - node?.closest(HOST_LAYER) ?? null; + +/** Shared listeners, so a page with many overlays adds one observer. */ +const listeners = new Set<() => void>(); +let observer: MutationObserver | null = null; + +const notify = () => listeners.forEach((listener) => listener()); + +/** Re-reads snapshots when the page's `dir` or `lang` changes. */ +const subscribe = (listener: () => void) => { + listeners.add(listener); + if (listeners.size === 1) { + observer = new MutationObserver(notify); + observer.observe(document.documentElement, { attributes: true, attributeFilter: ["dir", "lang"] }); + } + return () => { + listeners.delete(listener); + if (listeners.size === 0) { + observer?.disconnect(); + observer = null; + } + }; +}; + +/** + * The host layer of the element that last received focus. Updated on `focusin` + * only: while focus moves, `document.activeElement` is briefly ``, and an + * open overlay must not jump to `document.body` and back in between. + */ +const focusListeners = new Set<() => void>(); +let focusedHost: HTMLElement | null = null; + +const onFocusIn = (event: FocusEvent) => { + const host = getHostLayer(event.target instanceof Element ? event.target : null); + if (host === focusedHost) return; + focusedHost = host; + focusListeners.forEach((listener) => listener()); +}; + +const subscribeFocus = (listener: () => void) => { + focusListeners.add(listener); + if (focusListeners.size === 1) { + focusedHost = getHostLayer(document.activeElement); + document.addEventListener("focusin", onFocusIn, true); + } + return () => { + focusListeners.delete(listener); + if (focusListeners.size === 0) document.removeEventListener("focusin", onFocusIn, true); + }; +}; + +const getFocusedHost = () => focusedHost; + +const noHost = () => null; + +/** + * Escape inside our overlay should close our overlay only. Radix handles + * Escape in a capture listener on `document`, before React sees the key, and + * skips it when the event is already `defaultPrevented`. A capture listener + * on `window` runs first; React Aria does not look at `defaultPrevented`. + */ +let escapeGuard = false; +const guardEscape = () => { + if (escapeGuard || typeof window === "undefined") return; + escapeGuard = true; + window.addEventListener( + "keydown", + (event) => { + if (event.key === "Escape" && event.target instanceof Element && event.target.closest(`[${IN_HOST_LAYER}]`)) event.preventDefault(); + }, + true, + ); +}; + +/** + * The host layer an overlay anchored to `triggerRef` should render into, or + * null to keep React Aria's default (`document.body`). + */ +export const useHostLayer = (triggerRef?: RefObject | null): HTMLElement | null => { + const host = useSyncExternalStore(subscribe, () => getHostLayer(triggerRef?.current), noHost); + useLayoutEffect(() => { + if (host) guardEscape(); + }, [host]); + return host; +}; + +/** + * For overlays without a trigger element (modals opened from state): the host + * layer that holds focus when they open. A foreign modal layer keeps focus + * inside itself, so that is where the overlay was opened from. + */ +export const useFocusedHostLayer = (): HTMLElement | null => { + const host = useSyncExternalStore(subscribeFocus, getFocusedHost, noHost); + useLayoutEffect(() => { + if (host) guardEscape(); + }, [host]); + return host; +}; + +/** + * A modal layer that is `position: fixed` and transformed (e.g. centred with + * `translate(-50%, -50%)`) is the containing block of fixed descendants, so a + * full-screen overlay rendered inside it would only cover the layer. Offsets + * that put the overlay back over the viewport; `style` unchanged without a host. + */ +export const withHostLayerOffset = (host: HTMLElement | null, style: StyleProp): StyleProp => { + if (!host) return style; + return (values: T) => ({ ...(typeof style === "function" ? style(values) : style), ...hostLayerOffset(host) }); +}; + +type StyleProp = CSSProperties | ((values: T) => CSSProperties | undefined) | undefined; + +/** Whether `el` is the containing block of its `position: fixed` descendants (instead of the viewport). */ +const containsFixed = (el: Element) => { + const style = getComputedStyle(el); + return ( + [style.transform, style.translate, style.scale, style.rotate, style.perspective, style.filter, style.backdropFilter].some( + (value) => value && value !== "none", + ) || + /paint|layout|strict|content/.test(style.contain) || + /transform|translate|scale|rotate|perspective|filter/.test(style.willChange) || + (!!style.containerType && style.containerType !== "normal") + ); +}; + +const hostLayerOffset = (host: HTMLElement): CSSProperties => { + let block: HTMLElement | null = host; + while (block && block !== document.body && !containsFixed(block)) block = block.parentElement; + if (!block || block === document.body) return {}; + const rect = block.getBoundingClientRect(); + return { + top: -(rect.top + block.clientTop), + left: -(rect.left + block.clientLeft), + right: "auto", + bottom: "auto", + width: "100vw", + height: "100dvh", + }; +}; + +/** `lang` and explicit `dir` of the nearest element that sets them, starting at `node` (or the document). */ +const readHost = (node: Element | null | undefined) => { + if (typeof document === "undefined") return null; + const start = node ?? document.documentElement; + const dir = start.closest("[dir]")?.getAttribute("dir")?.toLowerCase(); + if (dir !== "rtl" && dir !== "ltr") return null; + return `${dir}|${start.closest("[lang]")?.getAttribute("lang") ?? ""}`; +}; + +/** + * React Aria takes direction from its locale (the browser language unless the + * app sets an `I18nProvider`), not from the page. When the page sets `dir` + * and it disagrees with that default locale, this returns a locale for the + * page's direction: the page's `lang` when it has that direction, otherwise + * a representative locale. Undefined (keep React Aria's locale) when the app + * chose a locale with its own `I18nProvider`, or the directions agree. + */ +export const useHostLocale = (triggerRef?: RefObject | null): string | undefined => { + const host = useSyncExternalStore(subscribe, () => readHost(triggerRef?.current), noHost); + const { locale, direction } = useLocale(); + if (!host) return undefined; + const [dir, lang] = host.split("|"); + if (dir === direction) return undefined; + // A locale other than the browser's default was chosen by the app: respect it. + if (typeof navigator !== "undefined" && navigator.language && locale !== navigator.language) return undefined; + const wantRtl = dir === "rtl"; + if (lang && safeIsRtl(lang) === wantRtl) return lang; + return wantRtl ? "ar" : "en-US"; +}; + +const safeIsRtl = (locale: string) => { + try { + return isRTL(locale); + } catch { + return null; + } +}; + +/** Wraps `children` in an `I18nProvider` for `locale`, or returns them untouched. */ +export const HostLocale = ({ locale, children }: { locale?: string; children: ReactNode }) => + locale ? {children} : children; + +/** + * React Aria's `Popover`, rendered into the host layer that contains its + * trigger and following the page's direction. Same props; identical to + * `Popover` when there is no host layer and no direction mismatch. + */ +export const OverlayPopover = (props: AriaPopoverProps & RefAttributes) => { + const context = useSlottedContext(AriaPopoverContext, props.slot); + const triggerRef = props.triggerRef ?? context?.triggerRef; + // Submenus render into their root popover's container; leave them there. + const isSubmenu = (props.trigger ?? context?.trigger) === "SubmenuTrigger"; + const host = useHostLayer(isSubmenu ? null : triggerRef); + const locale = useHostLocale(triggerRef); + + return ( + + + + ); +}; + +/** + * While one of our modals is open, overlays another library opens from inside + * it (a shadcn/ui DropdownMenu, Select or Popover) are portalled to + * `document.body`, outside the modal, where React Aria makes them inert and + * treats clicks and focus in them as outside the modal. Marking their roots as + * a top layer (`data-react-aria-top-layer`, React Aria's own escape hatch for + * toasts) keeps them usable. Only roots that are not React Aria's are marked. + * Runs in a layout effect so its observer is notified before React Aria's. + */ +const FOREIGN_OVERLAY_ROOT = + "[data-radix-popper-content-wrapper], [data-base-ui-portal], [data-floating-ui-portal], [data-state], [role=dialog], [role=alertdialog], [role=menu], [role=listbox], [role=tooltip]"; + +const isForeignOverlayRoot = (node: Node): node is HTMLElement => + node instanceof HTMLElement && + !node.hasAttribute("data-rac") && + !node.firstElementChild?.hasAttribute("data-rac") && + (node.matches(FOREIGN_OVERLAY_ROOT) || node.querySelector(FOREIGN_OVERLAY_ROOT) !== null); + +export const useForeignOverlaysOnTop = (isOpen = true) => { + useLayoutEffect(() => { + if (!isOpen) return; + const marked = new Set(); + const mark = (node: Node) => { + // Not the layer our own overlay sits in (see `useFocusedHostLayer`). + if (marked.has(node as HTMLElement) || !isForeignOverlayRoot(node) || node.querySelector(`[${IN_HOST_LAYER}]`)) return; + node.setAttribute("data-react-aria-top-layer", ""); + marked.add(node); + }; + const observer = new MutationObserver((records) => records.forEach((record) => record.addedNodes.forEach(mark))); + observer.observe(document.body, { childList: true }); + // Some libraries focus their overlay in the same task that mounts it, before the observer + // runs; mark it on the way in so React Aria's focus containment lets focus go there. + const onFocusIn = (event: FocusEvent) => { + let node = event.target instanceof Element ? event.target : null; + while (node?.parentElement && node.parentElement !== document.body) node = node.parentElement; + if (node?.parentElement === document.body) mark(node); + }; + document.addEventListener("focusin", onFocusIn, true); + return () => { + observer.disconnect(); + document.removeEventListener("focusin", onFocusIn, true); + marked.forEach((node) => node.removeAttribute("data-react-aria-top-layer")); + }; + }, [isOpen]); +}; From 94b72d4403ec538d29410ccf601b69026f24aee1 Mon Sep 17 00:00:00 2001 From: Dilshod Turobov Date: Sun, 27 Sep 2026 04:59:53 +0200 Subject: [PATCH 2/2] Split the overlay helper: hooks in overlay-host.ts, the popover component in overlay-popover.tsx Keeps Vite's fast refresh rule (only-export-components) quiet in users' projects. --- .../base-components/nav-account-card.tsx | 2 +- .../sidebar-navigation/sidebar-slim.tsx | 2 +- .../application/date-picker/date-picker.tsx | 2 +- .../date-picker/date-range-picker.tsx | 2 +- components/base/dropdown/dropdown.tsx | 2 +- components/base/select/multi-select.tsx | 2 +- components/base/select/popover.tsx | 2 +- utils/{overlay-host.tsx => overlay-host.ts} | 38 ++----------------- utils/overlay-popover.tsx | 25 ++++++++++++ 9 files changed, 35 insertions(+), 42 deletions(-) rename utils/{overlay-host.tsx => overlay-host.ts} (86%) create mode 100644 utils/overlay-popover.tsx diff --git a/components/application/app-navigation/base-components/nav-account-card.tsx b/components/application/app-navigation/base-components/nav-account-card.tsx index 1a0c90c..91f104f 100644 --- a/components/application/app-navigation/base-components/nav-account-card.tsx +++ b/components/application/app-navigation/base-components/nav-account-card.tsx @@ -11,7 +11,7 @@ import { Button } from "@/components/base/buttons/button"; import { RadioButtonBase } from "@/components/base/radio-buttons/radio-buttons"; import { useBreakpoint } from "@/hooks/use-breakpoint"; import { cx } from "@/utils/cx"; -import { OverlayPopover } from "@/utils/overlay-host"; +import { OverlayPopover } from "@/utils/overlay-popover"; export type NavAccountType = { /** Unique identifier for the nav item. */ diff --git a/components/application/app-navigation/sidebar-navigation/sidebar-slim.tsx b/components/application/app-navigation/sidebar-navigation/sidebar-slim.tsx index 7be0988..5f1a8be 100644 --- a/components/application/app-navigation/sidebar-navigation/sidebar-slim.tsx +++ b/components/application/app-navigation/sidebar-navigation/sidebar-slim.tsx @@ -10,7 +10,7 @@ import { ButtonUtility } from "@/components/base/buttons/button-utility"; import { UntitledLogo } from "@/components/foundations/logo/untitledui-logo"; import { UntitledLogoMinimal } from "@/components/foundations/logo/untitledui-logo-minimal"; import { cx } from "@/utils/cx"; -import { OverlayPopover } from "@/utils/overlay-host"; +import { OverlayPopover } from "@/utils/overlay-popover"; import { MobileNavigationHeader } from "../base-components/mobile-header"; import { NavAccountCard, NavAccountMenu } from "../base-components/nav-account-card"; import { NavButton } from "../base-components/nav-button"; diff --git a/components/application/date-picker/date-picker.tsx b/components/application/date-picker/date-picker.tsx index e7138cd..b44ede0 100644 --- a/components/application/date-picker/date-picker.tsx +++ b/components/application/date-picker/date-picker.tsx @@ -8,7 +8,7 @@ import type { DatePickerProps as AriaDatePickerProps, DateValue } from "react-ar import { DatePicker as AriaDatePicker, Dialog as AriaDialog, Group as AriaGroup } from "react-aria-components"; import { Button, type ButtonProps } from "@/components/base/buttons/button"; import { cx } from "@/utils/cx"; -import { OverlayPopover } from "@/utils/overlay-host"; +import { OverlayPopover } from "@/utils/overlay-popover"; import { Calendar } from "./calendar"; const highlightedDates = [today(getLocalTimeZone())]; diff --git a/components/application/date-picker/date-range-picker.tsx b/components/application/date-picker/date-range-picker.tsx index 47c192f..41510c1 100644 --- a/components/application/date-picker/date-range-picker.tsx +++ b/components/application/date-picker/date-range-picker.tsx @@ -10,7 +10,7 @@ import { DateRangePicker as AriaDateRangePicker, Dialog as AriaDialog, Group as import { Button, type ButtonProps } from "@/components/base/buttons/button"; import { InputDateBase } from "@/components/base/input/input-date"; import { cx } from "@/utils/cx"; -import { OverlayPopover } from "@/utils/overlay-host"; +import { OverlayPopover } from "@/utils/overlay-popover"; import { RangeCalendar, RangePresetButton } from "./range-calendar"; const now = today(getLocalTimeZone()); diff --git a/components/base/dropdown/dropdown.tsx b/components/base/dropdown/dropdown.tsx index fb6cfe6..4a8fe16 100644 --- a/components/base/dropdown/dropdown.tsx +++ b/components/base/dropdown/dropdown.tsx @@ -20,7 +20,7 @@ import { Separator as AriaSeparator, } from "react-aria-components"; import { cx } from "@/utils/cx"; -import { OverlayPopover } from "@/utils/overlay-host"; +import { OverlayPopover } from "@/utils/overlay-popover"; import { Avatar } from "../avatar/avatar"; import { CheckboxBase } from "../checkbox/checkbox"; import { RadioButtonBase } from "../radio-buttons/radio-buttons"; diff --git a/components/base/select/multi-select.tsx b/components/base/select/multi-select.tsx index 6f6071b..18ec640 100644 --- a/components/base/select/multi-select.tsx +++ b/components/base/select/multi-select.tsx @@ -20,7 +20,7 @@ import { Label } from "@/components/base/input/label"; import { FeaturedIcon } from "@/components/foundations/featured-icon/featured-icon"; import { cx } from "@/utils/cx"; import { isReactComponent } from "@/utils/is-react-component"; -import { OverlayPopover } from "@/utils/overlay-host"; +import { OverlayPopover } from "@/utils/overlay-popover"; import { SelectItem } from "./select-item"; import { type CommonProps, SelectContext, type SelectItemType, sizes } from "./select-shared"; diff --git a/components/base/select/popover.tsx b/components/base/select/popover.tsx index 31b1b89..08f7724 100644 --- a/components/base/select/popover.tsx +++ b/components/base/select/popover.tsx @@ -3,7 +3,7 @@ import type { RefAttributes } from "react"; import type { PopoverProps as AriaPopoverProps } from "react-aria-components"; import { cx } from "@/utils/cx"; -import { OverlayPopover } from "@/utils/overlay-host"; +import { OverlayPopover } from "@/utils/overlay-popover"; interface PopoverProps extends AriaPopoverProps, RefAttributes { size: "sm" | "md" | "lg"; diff --git a/utils/overlay-host.tsx b/utils/overlay-host.ts similarity index 86% rename from utils/overlay-host.tsx rename to utils/overlay-host.ts index d2685f8..4968518 100644 --- a/utils/overlay-host.tsx +++ b/utils/overlay-host.ts @@ -1,16 +1,8 @@ "use client"; -import type { CSSProperties, ReactNode, RefAttributes, RefObject } from "react"; +import type { CSSProperties, RefObject } from "react"; import { useLayoutEffect, useSyncExternalStore } from "react"; -import type { PopoverProps as AriaPopoverProps } from "react-aria-components"; -import { - I18nProvider as AriaI18nProvider, - Popover as AriaPopover, - PopoverContext as AriaPopoverContext, - isRTL, - useLocale, - useSlottedContext, -} from "react-aria-components"; +import { isRTL, useLocale } from "react-aria-components"; /** * Overlays inside other libraries' modal layers. @@ -136,7 +128,7 @@ export const useFocusedHostLayer = (): HTMLElement | null => { * full-screen overlay rendered inside it would only cover the layer. Offsets * that put the overlay back over the viewport; `style` unchanged without a host. */ -export const withHostLayerOffset = (host: HTMLElement | null, style: StyleProp): StyleProp => { +export const withHostLayerOffset = (host: HTMLElement | null, style: StyleProp): StyleProp => { if (!host) return style; return (values: T) => ({ ...(typeof style === "function" ? style(values) : style), ...hostLayerOffset(host) }); }; @@ -209,30 +201,6 @@ const safeIsRtl = (locale: string) => { } }; -/** Wraps `children` in an `I18nProvider` for `locale`, or returns them untouched. */ -export const HostLocale = ({ locale, children }: { locale?: string; children: ReactNode }) => - locale ? {children} : children; - -/** - * React Aria's `Popover`, rendered into the host layer that contains its - * trigger and following the page's direction. Same props; identical to - * `Popover` when there is no host layer and no direction mismatch. - */ -export const OverlayPopover = (props: AriaPopoverProps & RefAttributes) => { - const context = useSlottedContext(AriaPopoverContext, props.slot); - const triggerRef = props.triggerRef ?? context?.triggerRef; - // Submenus render into their root popover's container; leave them there. - const isSubmenu = (props.trigger ?? context?.trigger) === "SubmenuTrigger"; - const host = useHostLayer(isSubmenu ? null : triggerRef); - const locale = useHostLocale(triggerRef); - - return ( - - - - ); -}; - /** * While one of our modals is open, overlays another library opens from inside * it (a shadcn/ui DropdownMenu, Select or Popover) are portalled to diff --git a/utils/overlay-popover.tsx b/utils/overlay-popover.tsx new file mode 100644 index 0000000..093420d --- /dev/null +++ b/utils/overlay-popover.tsx @@ -0,0 +1,25 @@ +"use client"; + +import type { RefAttributes } from "react"; +import type { PopoverProps as AriaPopoverProps } from "react-aria-components"; +import { I18nProvider as AriaI18nProvider, Popover as AriaPopover, PopoverContext as AriaPopoverContext, useSlottedContext } from "react-aria-components"; +import { IN_HOST_LAYER, useHostLayer, useHostLocale } from "@/utils/overlay-host"; + +/** + * React Aria's `Popover`, rendered into another library's modal layer when its + * trigger sits in one (e.g. a shadcn/ui Dialog), and following the page's + * direction. Same props; identical to `Popover` when there is no such layer + * and no direction mismatch. See `overlay-host.ts`. + */ +export const OverlayPopover = (props: AriaPopoverProps & RefAttributes) => { + const context = useSlottedContext(AriaPopoverContext, props.slot); + const triggerRef = props.triggerRef ?? context?.triggerRef; + // Submenus render into their root popover's container; leave them there. + const isSubmenu = (props.trigger ?? context?.trigger) === "SubmenuTrigger"; + const host = useHostLayer(isSubmenu ? null : triggerRef); + const locale = useHostLocale(triggerRef); + + const popover = ; + + return locale ? {popover} : popover; +};