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;
+};