diff --git a/apps/web/src/components/ui/alert-dialog.tsx b/apps/web/src/components/ui/alert-dialog.tsx
index 4f57e920118b..2b7675dc7c9e 100644
--- a/apps/web/src/components/ui/alert-dialog.tsx
+++ b/apps/web/src/components/ui/alert-dialog.tsx
@@ -7,6 +7,7 @@ import {
DIALOG_BACKDROP_CLASS,
DIALOG_MOBILE_SHEET_CLASS,
DIALOG_POPUP_CLASS,
+ resolveDialogBackdropStyle,
} from "~/components/ui/dialog-styles";
const AlertDialogCreateHandle = AlertDialogPrimitive.createHandle;
@@ -19,12 +20,15 @@ function AlertDialogTrigger(props: AlertDialogPrimitive.Trigger.Props) {
return ;
}
-function AlertDialogBackdrop({ className, ...props }: AlertDialogPrimitive.Backdrop.Props) {
+function AlertDialogBackdrop({ className, style, ...props }: AlertDialogPrimitive.Backdrop.Props) {
return (
+ resolveDialogBackdropStyle(state.open, typeof style === "function" ? style(state) : style)
+ }
{...props}
/>
);
diff --git a/apps/web/src/components/ui/command.tsx b/apps/web/src/components/ui/command.tsx
index c1952e9c6f36..6e00222f3630 100644
--- a/apps/web/src/components/ui/command.tsx
+++ b/apps/web/src/components/ui/command.tsx
@@ -15,7 +15,11 @@ import {
AutocompleteList,
AutocompleteSeparator,
} from "~/components/ui/autocomplete";
-import { DIALOG_BACKDROP_CLASS, DIALOG_POPUP_CLASS } from "~/components/ui/dialog-styles";
+import {
+ DIALOG_BACKDROP_CLASS,
+ DIALOG_POPUP_CLASS,
+ resolveDialogBackdropStyle,
+} from "~/components/ui/dialog-styles";
import { Button } from "~/components/ui/button";
const CommandDialog = CommandDialogPrimitive.Root;
@@ -28,11 +32,18 @@ function CommandDialogTrigger(props: CommandDialogPrimitive.Trigger.Props) {
return ;
}
-function CommandDialogBackdrop({ className, ...props }: CommandDialogPrimitive.Backdrop.Props) {
+function CommandDialogBackdrop({
+ className,
+ style,
+ ...props
+}: CommandDialogPrimitive.Backdrop.Props) {
return (
+ resolveDialogBackdropStyle(state.open, typeof style === "function" ? style(state) : style)
+ }
{...props}
/>
);
diff --git a/apps/web/src/components/ui/dialog-styles.test.ts b/apps/web/src/components/ui/dialog-styles.test.ts
new file mode 100644
index 000000000000..6c4fdf0d164b
--- /dev/null
+++ b/apps/web/src/components/ui/dialog-styles.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { resolveDialogBackdropStyle } from "./dialog-styles";
+
+describe("dialog backdrop interaction states", () => {
+ it("stops intercepting pointer input while the dialog finishes closing", () => {
+ expect(resolveDialogBackdropStyle(false, { pointerEvents: "auto" })).toMatchObject({
+ pointerEvents: "none",
+ });
+ });
+
+ it("preserves caller styles while the dialog is open", () => {
+ const style = { cursor: "wait" } as const;
+ expect(resolveDialogBackdropStyle(true, style)).toBe(style);
+ });
+});
diff --git a/apps/web/src/components/ui/dialog-styles.ts b/apps/web/src/components/ui/dialog-styles.ts
index 15e9e8880742..f4dc543388fa 100644
--- a/apps/web/src/components/ui/dialog-styles.ts
+++ b/apps/web/src/components/ui/dialog-styles.ts
@@ -1,10 +1,25 @@
+import type { CSSProperties } from "react";
+
const DIALOG_BACKDROP_CLASS =
"dialog-backdrop fixed inset-0 z-50 transition-all duration-200 data-ending-style:opacity-0 data-starting-style:opacity-0";
+function resolveDialogBackdropStyle(
+ open: boolean,
+ style: CSSProperties | undefined,
+): CSSProperties | undefined {
+ if (open) return style;
+ return { ...style, pointerEvents: "none" };
+}
+
const DIALOG_POPUP_CLASS =
"dialog-glass -translate-y-[calc(1.25rem*var(--nested-dialogs))] relative flex min-h-0 w-full min-w-0 scale-[calc(1-0.1*var(--nested-dialogs))] flex-col rounded-2xl border opacity-[calc(1-0.1*var(--nested-dialogs))] outline-none transition-[scale,opacity,translate] duration-200 ease-in-out will-change-transform data-nested:data-ending-style:translate-y-8 data-nested:data-starting-style:translate-y-8 data-nested-dialog-open:origin-top data-ending-style:scale-98 data-starting-style:scale-98 data-ending-style:opacity-0 data-starting-style:opacity-0";
const DIALOG_MOBILE_SHEET_CLASS =
"max-sm:max-w-none max-sm:rounded-none max-sm:border-x-0 max-sm:border-t max-sm:border-b-0 max-sm:opacity-[calc(1-min(var(--nested-dialogs),1))] max-sm:data-ending-style:translate-y-4 max-sm:data-starting-style:translate-y-4";
-export { DIALOG_BACKDROP_CLASS, DIALOG_MOBILE_SHEET_CLASS, DIALOG_POPUP_CLASS };
+export {
+ DIALOG_BACKDROP_CLASS,
+ DIALOG_MOBILE_SHEET_CLASS,
+ DIALOG_POPUP_CLASS,
+ resolveDialogBackdropStyle,
+};
diff --git a/apps/web/src/components/ui/dialog.test.tsx b/apps/web/src/components/ui/dialog.test.tsx
new file mode 100644
index 000000000000..044aa6767a77
--- /dev/null
+++ b/apps/web/src/components/ui/dialog.test.tsx
@@ -0,0 +1,34 @@
+import { renderToStaticMarkup } from "react-dom/server";
+import { describe, expect, it, vi } from "vite-plus/test";
+
+vi.mock("@base-ui/react/dialog", () => ({
+ Dialog: {
+ createHandle: () => ({}),
+ Root: "div",
+ Portal: "div",
+ Trigger: "button",
+ Close: "button",
+ Backdrop: ({ forceRender: _forceRender, style, ...props }: Record) => (
+
+ ),
+ Viewport: "div",
+ Popup: "div",
+ Title: "h2",
+ Description: "p",
+ },
+}));
+
+import { DialogBackdrop } from "./dialog";
+
+describe("DialogBackdrop", () => {
+ it("does not intercept input when Base UI keeps it mounted while closing", () => {
+ const markup = renderToStaticMarkup();
+
+ expect(markup).toContain('style="pointer-events:none"');
+ });
+});
diff --git a/apps/web/src/components/ui/dialog.tsx b/apps/web/src/components/ui/dialog.tsx
index a96f648803aa..bfbf36989426 100644
--- a/apps/web/src/components/ui/dialog.tsx
+++ b/apps/web/src/components/ui/dialog.tsx
@@ -8,6 +8,7 @@ import {
DIALOG_BACKDROP_CLASS,
DIALOG_MOBILE_SHEET_CLASS,
DIALOG_POPUP_CLASS,
+ resolveDialogBackdropStyle,
} from "~/components/ui/dialog-styles";
import { ScrollArea } from "~/components/ui/scroll-area";
@@ -25,12 +26,15 @@ function DialogClose(props: DialogPrimitive.Close.Props) {
return ;
}
-function DialogBackdrop({ className, ...props }: DialogPrimitive.Backdrop.Props) {
+function DialogBackdrop({ className, style, ...props }: DialogPrimitive.Backdrop.Props) {
return (
+ resolveDialogBackdropStyle(state.open, typeof style === "function" ? style(state) : style)
+ }
{...props}
/>
);
diff --git a/apps/web/src/components/ui/sheet.tsx b/apps/web/src/components/ui/sheet.tsx
index 9d0436b661da..8a35da8273c3 100644
--- a/apps/web/src/components/ui/sheet.tsx
+++ b/apps/web/src/components/ui/sheet.tsx
@@ -4,6 +4,7 @@ import { Dialog as SheetPrimitive } from "@base-ui/react/dialog";
import { XIcon } from "lucide-react";
import { cn } from "~/lib/utils";
import { Button } from "~/components/ui/button";
+import { resolveDialogBackdropStyle } from "~/components/ui/dialog-styles";
import { ScrollArea } from "~/components/ui/scroll-area";
const Sheet = SheetPrimitive.Root;
@@ -18,7 +19,7 @@ function SheetClose(props: SheetPrimitive.Close.Props) {
return ;
}
-function SheetBackdrop({ className, ...props }: SheetPrimitive.Backdrop.Props) {
+function SheetBackdrop({ className, style, ...props }: SheetPrimitive.Backdrop.Props) {
return (
+ resolveDialogBackdropStyle(state.open, typeof style === "function" ? style(state) : style)
+ }
{...props}
/>
);