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} /> );