From b3549e823e1d36f019c71ffff2b1f8e3a39150a5 Mon Sep 17 00:00:00 2001 From: Szymon Bludnik Date: Thu, 30 Jul 2026 17:38:34 +0200 Subject: [PATCH] fix: prevent dialogs/popovers from closing when browser window loses focus Radix's DismissableLayer can mistake a window blur/refocus (eg. clicking outside the browser, alt-tabbing, or switching tabs) for an outside interaction and dismiss the topmost overlay. This closed modals such as "Create Service" and "Create from Template" whenever the page lost focus. Track window blur/focus globally and ignore spurious onFocusOutside/onInteractOutside dismissals in Dialog, AlertDialog, Sheet, Popover and DropdownMenu content components. --- apps/dokploy/components/ui/alert-dialog.tsx | 9 ++++++ apps/dokploy/components/ui/dialog.tsx | 21 +++++++++++++- apps/dokploy/components/ui/dropdown-menu.tsx | 21 +++++++++++++- .../components/ui/nested-popup-context.ts | 16 +++++++++++ apps/dokploy/components/ui/popover.tsx | 21 +++++++++++++- apps/dokploy/components/ui/sheet.tsx | 28 +++++++++++++++++++ 6 files changed, 113 insertions(+), 3 deletions(-) diff --git a/apps/dokploy/components/ui/alert-dialog.tsx b/apps/dokploy/components/ui/alert-dialog.tsx index 9674276a88..b661de8331 100644 --- a/apps/dokploy/components/ui/alert-dialog.tsx +++ b/apps/dokploy/components/ui/alert-dialog.tsx @@ -3,6 +3,7 @@ import { AlertDialog as AlertDialogPrimitive } from "radix-ui"; import type * as React from "react"; import { Button } from "@/components/ui/button"; +import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context"; import { cn } from "@/lib/utils"; function AlertDialog({ @@ -46,6 +47,7 @@ function AlertDialogOverlay({ function AlertDialogContent({ className, size = "default", + onEscapeKeyDown, ...props }: React.ComponentProps & { size?: "default" | "sm"; @@ -60,6 +62,13 @@ function AlertDialogContent({ "group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-6 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-lg data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-lg data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className, )} + onEscapeKeyDown={(event) => { + if (wasNestedPopupJustClosed()) { + event.preventDefault(); + return; + } + onEscapeKeyDown?.(event); + }} {...props} /> diff --git a/apps/dokploy/components/ui/dialog.tsx b/apps/dokploy/components/ui/dialog.tsx index 21ad3921fb..511e5ea44a 100644 --- a/apps/dokploy/components/ui/dialog.tsx +++ b/apps/dokploy/components/ui/dialog.tsx @@ -2,7 +2,10 @@ import { XIcon } from "lucide-react"; import { Dialog as DialogPrimitive } from "radix-ui"; import type * as React from "react"; import { Button } from "@/components/ui/button"; -import { wasNestedPopupJustClosed } from "@/components/ui/nested-popup-context"; +import { + wasNestedPopupJustClosed, + wasWindowRecentlyBlurred, +} from "@/components/ui/nested-popup-context"; import { cn } from "@/lib/utils"; function Dialog({ @@ -51,6 +54,8 @@ function DialogContent({ showCloseButton = true, onPointerDownOutside, onEscapeKeyDown, + onInteractOutside, + onFocusOutside, ...props }: React.ComponentProps & { showCloseButton?: boolean; @@ -78,6 +83,20 @@ function DialogContent({ } onEscapeKeyDown?.(event); }} + onFocusOutside={(event) => { + if (wasWindowRecentlyBlurred()) { + event.preventDefault(); + return; + } + onFocusOutside?.(event); + }} + onInteractOutside={(event) => { + if (wasWindowRecentlyBlurred()) { + event.preventDefault(); + return; + } + onInteractOutside?.(event); + }} {...props} > {children} diff --git a/apps/dokploy/components/ui/dropdown-menu.tsx b/apps/dokploy/components/ui/dropdown-menu.tsx index bf06bb708f..dc1d56240f 100644 --- a/apps/dokploy/components/ui/dropdown-menu.tsx +++ b/apps/dokploy/components/ui/dropdown-menu.tsx @@ -1,7 +1,10 @@ import { CheckIcon, ChevronRightIcon } from "lucide-react"; import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"; import type * as React from "react"; -import { markNestedPopupClosed } from "@/components/ui/nested-popup-context"; +import { + markNestedPopupClosed, + wasWindowRecentlyBlurred, +} from "@/components/ui/nested-popup-context"; import { cn } from "@/lib/utils"; function DropdownMenu({ @@ -45,6 +48,8 @@ function DropdownMenuContent({ className, align = "start", sideOffset = 4, + onInteractOutside, + onFocusOutside, ...props }: React.ComponentProps) { return ( @@ -57,6 +62,20 @@ function DropdownMenuContent({ "z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className, )} + onFocusOutside={(event) => { + if (wasWindowRecentlyBlurred()) { + event.preventDefault(); + return; + } + onFocusOutside?.(event); + }} + onInteractOutside={(event) => { + if (wasWindowRecentlyBlurred()) { + event.preventDefault(); + return; + } + onInteractOutside?.(event); + }} {...props} /> diff --git a/apps/dokploy/components/ui/nested-popup-context.ts b/apps/dokploy/components/ui/nested-popup-context.ts index cbb7548a69..06f9f4a2fe 100644 --- a/apps/dokploy/components/ui/nested-popup-context.ts +++ b/apps/dokploy/components/ui/nested-popup-context.ts @@ -7,3 +7,19 @@ export function markNestedPopupClosed() { export function wasNestedPopupJustClosed() { return performance.now() - lastNestedPopupCloseAt < 100; } + + +let lastWindowFocusChangeAt = 0; + +if (typeof window !== "undefined") { + const markWindowFocusChange = () => { + lastWindowFocusChangeAt = performance.now(); + }; + window.addEventListener("blur", markWindowFocusChange); + window.addEventListener("focus", markWindowFocusChange); + document.addEventListener("visibilitychange", markWindowFocusChange); +} + +export function wasWindowRecentlyBlurred() { + return performance.now() - lastWindowFocusChangeAt < 150; +} diff --git a/apps/dokploy/components/ui/popover.tsx b/apps/dokploy/components/ui/popover.tsx index 05e8d2c539..f2e400b00a 100644 --- a/apps/dokploy/components/ui/popover.tsx +++ b/apps/dokploy/components/ui/popover.tsx @@ -2,7 +2,10 @@ import { Popover as PopoverPrimitive } from "radix-ui"; import type * as React from "react"; -import { markNestedPopupClosed } from "@/components/ui/nested-popup-context"; +import { + markNestedPopupClosed, + wasWindowRecentlyBlurred, +} from "@/components/ui/nested-popup-context"; import { cn } from "@/lib/utils"; function Popover({ @@ -33,6 +36,8 @@ function PopoverContent({ className, align = "center", sideOffset = 4, + onInteractOutside, + onFocusOutside, ...props }: React.ComponentProps) { return ( @@ -45,6 +50,20 @@ function PopoverContent({ "z-50 flex w-72 origin-(--radix-popover-content-transform-origin) flex-col gap-2.5 rounded-lg bg-popover p-4 text-sm text-popover-foreground shadow-md ring-1 ring-foreground/10 outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95", className, )} + onFocusOutside={(event) => { + if (wasWindowRecentlyBlurred()) { + event.preventDefault(); + return; + } + onFocusOutside?.(event); + }} + onInteractOutside={(event) => { + if (wasWindowRecentlyBlurred()) { + event.preventDefault(); + return; + } + onInteractOutside?.(event); + }} {...props} /> diff --git a/apps/dokploy/components/ui/sheet.tsx b/apps/dokploy/components/ui/sheet.tsx index 4c0c8c5765..6b04bddd70 100644 --- a/apps/dokploy/components/ui/sheet.tsx +++ b/apps/dokploy/components/ui/sheet.tsx @@ -2,6 +2,10 @@ import { XIcon } from "lucide-react"; import { Dialog as SheetPrimitive } from "radix-ui"; import type * as React from "react"; import { Button } from "@/components/ui/button"; +import { + wasNestedPopupJustClosed, + wasWindowRecentlyBlurred, +} from "@/components/ui/nested-popup-context"; import { cn } from "@/lib/utils"; function Sheet({ ...props }: React.ComponentProps) { @@ -47,6 +51,9 @@ function SheetContent({ children, side = "right", showCloseButton = true, + onEscapeKeyDown, + onInteractOutside, + onFocusOutside, ...props }: React.ComponentProps & { side?: "top" | "right" | "bottom" | "left"; @@ -62,6 +69,27 @@ function SheetContent({ "fixed z-50 flex flex-col gap-4 bg-popover bg-clip-padding text-sm text-popover-foreground shadow-lg transition duration-200 ease-in-out data-[side=bottom]:inset-x-0 data-[side=bottom]:bottom-0 data-[side=bottom]:h-auto data-[side=bottom]:border-t data-[side=left]:inset-y-0 data-[side=left]:left-0 data-[side=left]:h-full data-[side=left]:w-3/4 data-[side=left]:border-r data-[side=right]:inset-y-0 data-[side=right]:right-0 data-[side=right]:h-full data-[side=right]:w-3/4 data-[side=right]:border-l data-[side=top]:inset-x-0 data-[side=top]:top-0 data-[side=top]:h-auto data-[side=top]:border-b data-[side=left]:sm:max-w-sm data-[side=right]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-[side=bottom]:data-open:slide-in-from-bottom-10 data-[side=left]:data-open:slide-in-from-left-10 data-[side=right]:data-open:slide-in-from-right-10 data-[side=top]:data-open:slide-in-from-top-10 data-closed:animate-out data-closed:fade-out-0 data-[side=bottom]:data-closed:slide-out-to-bottom-10 data-[side=left]:data-closed:slide-out-to-left-10 data-[side=right]:data-closed:slide-out-to-right-10 data-[side=top]:data-closed:slide-out-to-top-10", className, )} + onEscapeKeyDown={(event) => { + if (wasNestedPopupJustClosed()) { + event.preventDefault(); + return; + } + onEscapeKeyDown?.(event); + }} + onFocusOutside={(event) => { + if (wasWindowRecentlyBlurred()) { + event.preventDefault(); + return; + } + onFocusOutside?.(event); + }} + onInteractOutside={(event) => { + if (wasWindowRecentlyBlurred()) { + event.preventDefault(); + return; + } + onInteractOutside?.(event); + }} {...props} > {children}