diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx index c9cc95bd1..07bf0a1a0 100644 --- a/frontend/src/components/ui/dialog.tsx +++ b/frontend/src/components/ui/dialog.tsx @@ -6,6 +6,7 @@ import { Dialog as DialogPrimitive } from "radix-ui" import { cn } from "@/lib/utils" import { Button } from "@/components/ui/button" +import { useFloatingLayerDismissGuard } from "@/components/ui/use-floating-layer-dismiss-guard" function Dialog({ ...props @@ -51,10 +52,15 @@ function DialogContent({ className, children, showCloseButton = true, + onFocusOutside, + onInteractOutside, + onPointerDownOutside, ...props }: React.ComponentProps & { showCloseButton?: boolean }) { + const guardDismiss = useFloatingLayerDismissGuard() + return ( @@ -64,6 +70,18 @@ function DialogContent({ "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 overflow-hidden rounded-lg border p-6 shadow-lg duration-200 outline-none overscroll-contain sm:max-w-lg", className )} + onFocusOutside={(event) => { + onFocusOutside?.(event) + guardDismiss(event) + }} + onInteractOutside={(event) => { + onInteractOutside?.(event) + guardDismiss(event) + }} + onPointerDownOutside={(event) => { + onPointerDownOutside?.(event) + guardDismiss(event) + }} {...props} > {children} diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx index 423f1d047..4b5672234 100644 --- a/frontend/src/components/ui/sheet.tsx +++ b/frontend/src/components/ui/sheet.tsx @@ -3,6 +3,7 @@ import { XIcon } from "lucide-react" import { Dialog as SheetPrimitive } from "radix-ui" import { cn } from "@/lib/utils" +import { useFloatingLayerDismissGuard } from "@/components/ui/use-floating-layer-dismiss-guard" function Sheet({ ...props }: React.ComponentProps) { return @@ -47,16 +48,35 @@ function SheetContent({ children, side = "right", showCloseButton = true, + onFocusOutside, + onInteractOutside, + onPointerDownOutside, ...props }: React.ComponentProps & { side?: "top" | "right" | "bottom" | "left" showCloseButton?: boolean }) { + // Keep the sheet open when an outside interaction is really the user + // dismissing a nested floating layer (Select / DropdownMenu / Popover). + const guardDismiss = useFloatingLayerDismissGuard() + return ( { + onFocusOutside?.(event) + guardDismiss(event) + }} + onInteractOutside={(event) => { + onInteractOutside?.(event) + guardDismiss(event) + }} + onPointerDownOutside={(event) => { + onPointerDownOutside?.(event) + guardDismiss(event) + }} className={cn( "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out overscroll-contain data-[state=closed]:duration-300 data-[state=open]:duration-500", side === "right" && diff --git a/frontend/src/components/ui/use-floating-layer-dismiss-guard.ts b/frontend/src/components/ui/use-floating-layer-dismiss-guard.ts new file mode 100644 index 000000000..85743c834 --- /dev/null +++ b/frontend/src/components/ui/use-floating-layer-dismiss-guard.ts @@ -0,0 +1,77 @@ +"use client" + +import * as React from "react" + +// Floating layers (Radix Select / DropdownMenu / Popover, or anything rendered +// through a Radix popper) that can appear inside a modal. Clicking inside one — +// or outside one while it is open — must dismiss the LAYER, never the modal. +const FLOATING_LAYER_SELECTOR = [ + "[data-slot='select-content']", + "[data-slot='dropdown-menu-content']", + "[data-slot='dropdown-menu-sub-content']", + "[data-slot='popover-content']", + "[data-radix-popper-content-wrapper]", +].join(", ") + +const OPEN_FLOATING_LAYER_SELECTOR = [ + "[data-slot='select-content'][data-state='open']", + "[data-slot='dropdown-menu-content'][data-state='open']", + "[data-slot='dropdown-menu-sub-content'][data-state='open']", + "[data-slot='popover-content'][data-state='open']", +].join(", ") + +function isFloatingLayerTarget(target: EventTarget | null): boolean { + return target instanceof Element && target.closest(FLOATING_LAYER_SELECTOR) !== null +} + +function isFloatingLayerOpen(): boolean { + return ( + typeof document !== "undefined" && + document.querySelector(OPEN_FLOATING_LAYER_SELECTOR) !== null + ) +} + +// Minimal shape shared by Radix's FocusOutside / PointerDownOutside / +// InteractOutside events. +type DismissEvent = { + target: EventTarget | null + defaultPrevented: boolean + preventDefault: () => void +} + +/** + * Guard for a modal's onFocusOutside / onInteractOutside / onPointerDownOutside: + * keeps the modal open when the "outside" interaction is really the user + * dismissing a nested floating layer (Select / DropdownMenu / Popover / custom + * multi-select), instead of closing the modal. + * + * The captured-at-pointerdown flag is essential: a mouse click outside an OPEN + * dropdown makes Radix close that dropdown FIRST (its data-state flips to + * "closed") before the modal's outside handler runs, so a live DOM check would + * already miss it and the modal would wrongly close. We capture the open-state + * in a document pointerdown listener on the CAPTURE phase, which runs before + * Radix's own bubble-phase dismiss listeners. + */ +export function useFloatingLayerDismissGuard() { + const floatingLayerOpenAtPointerDownRef = React.useRef(false) + + React.useEffect(() => { + const onPointerDownCapture = () => { + floatingLayerOpenAtPointerDownRef.current = isFloatingLayerOpen() + } + document.addEventListener("pointerdown", onPointerDownCapture, true) + return () => + document.removeEventListener("pointerdown", onPointerDownCapture, true) + }, []) + + return React.useCallback((event: DismissEvent) => { + if ( + !event.defaultPrevented && + (isFloatingLayerTarget(event.target) || + floatingLayerOpenAtPointerDownRef.current || + isFloatingLayerOpen()) + ) { + event.preventDefault() + } + }, []) +} diff --git a/frontend/src/features/accounts/components/account-actions.tsx b/frontend/src/features/accounts/components/account-actions.tsx index 6092b0ea8..866f433f3 100644 --- a/frontend/src/features/accounts/components/account-actions.tsx +++ b/frontend/src/features/accounts/components/account-actions.tsx @@ -64,8 +64,8 @@ export function AccountActions({ return (
{!showOperatorRecoveryAction ? ( -
-
+
+
Routing policy
@@ -82,7 +82,7 @@ export function AccountActions({ @@ -97,7 +97,7 @@ export function AccountActions({