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..91f104f 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-popover"; 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..5f1a8be 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-popover"; 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..b44ede0 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-popover"; 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..41510c1 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-popover"; 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..18ec640 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-popover"; 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..08f7724 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-popover"; 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; + } +}; + +/** + * 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]); +}; 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; +};