Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions src/components/CommandSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ChevronDown,
} from "lucide-react";
import { cn } from "./lib/utils";
import { useDismissGuard } from "./ui/useDismissGuard";
import {
DropdownMenu,
DropdownMenuTrigger,
Expand Down Expand Up @@ -313,12 +314,19 @@ export default function CommandSearch({
}, [selectedIndex]);

const hasResults = flatItems.length > 0;
const { registerContent, shouldBlockDismiss } = useDismissGuard<HTMLDivElement>();

return (
<DialogPrimitive.Root open={open} onOpenChange={onOpenChange}>
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
ref={registerContent}
onInteractOutside={(e) => {
// The filter dropdown makes this panel inert while it is open, so
// the click that closes it lands on the overlay — see useDismissGuard.
if (shouldBlockDismiss(e)) e.preventDefault();
}}
className={cn(
"fixed left-[50%] top-[18%] z-50 w-full max-w-xl translate-x-[-50%]",
"rounded-xl border border-border/60 bg-card shadow-2xl overflow-hidden",
Expand Down
9 changes: 9 additions & 0 deletions src/components/ui/SidebarModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { SettingsLayoutProvider } from "./useSettingsLayout";
import { useDismissGuard } from "./useDismissGuard";

export interface SidebarItem<T extends string> {
id: T;
Expand Down Expand Up @@ -42,6 +43,7 @@ export default function SidebarModal<T extends string>({
header,
}: SidebarModalProps<T>) {
const { t } = useTranslation();
const { registerContent, shouldBlockDismiss } = useDismissGuard<HTMLDivElement>();

const [isCompact, setIsCompact] = React.useState(false);
const observerRef = React.useRef<ResizeObserver | null>(null);
Expand Down Expand Up @@ -107,9 +109,16 @@ export default function SidebarModal<T extends string>({
<DialogPrimitive.Portal>
<DialogPrimitive.Overlay className="fixed inset-0 z-50 bg-black/60 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<DialogPrimitive.Content
ref={registerContent}
onEscapeKeyDown={(e) => {
if (document.querySelector("[data-capturing]")) e.preventDefault();
}}
onInteractOutside={(e) => {
// A dropdown open over this panel makes the panel inert, so the
// click that closes the dropdown lands on the overlay and would
// otherwise take the whole settings modal with it.
if (shouldBlockDismiss(e)) e.preventDefault();
}}
className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-4xl translate-x-[-50%] translate-y-[-50%] rounded-xl p-0 overflow-hidden bg-background border border-border shadow-[0_25px_50px_-12px_rgba(0,0,0,0.25)] dark:bg-surface-1 dark:border-border-subtle dark:shadow-[0_25px_60px_-12px_rgba(0,0,0,0.5),0_0_0_1px_rgba(255,255,255,0.05)] duration-200 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-98 data-[state=open]:zoom-in-98"
>
<div className="relative h-full max-h-[85vh] overflow-hidden">
Expand Down
38 changes: 5 additions & 33 deletions src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import * as DialogPrimitive from "@radix-ui/react-dialog";
import { X } from "lucide-react";
import { cn } from "../lib/utils";
import { Button } from "./button";
import { useDismissGuard } from "./useDismissGuard";

const Dialog = DialogPrimitive.Root;

Expand Down Expand Up @@ -31,47 +32,18 @@ const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & { overlayClassName?: string }
>(({ className, children, onInteractOutside, overlayClassName, ...props }, ref) => {
// With another layer open above this dialog — a popper (Select/Popover/
// DropdownMenu) or a stacked dialog — an outside click dismisses that
// layer, never this dialog. Radix defers outside-click dismissal to a
// one-time document `click` listener, and the upper layer can unmount
// before it runs (e.g. a stacked dialog's Cancel closes it mid-click),
// which un-gates this layer's own dismissal. So "was something above us"
// must be snapshotted at pointerdown capture time, ahead of every Radix
// handler.
const contentRef = React.useRef<React.ElementRef<typeof DialogPrimitive.Content> | null>(null);
const layerWasAboveRef = React.useRef(false);
React.useEffect(() => {
const snapshotLayersAbove = () => {
// Later-mounted portals stack on top, so the last open dialog in DOM
// order is the topmost one.
const openDialogs = document.querySelectorAll('[role="dialog"][data-state="open"]');
layerWasAboveRef.current =
!!document.querySelector("[data-radix-popper-content-wrapper]") ||
(openDialogs.length > 0 && openDialogs[openDialogs.length - 1] !== contentRef.current);
};
document.addEventListener("pointerdown", snapshotLayersAbove, { capture: true });
return () => {
document.removeEventListener("pointerdown", snapshotLayersAbove, { capture: true });
};
}, []);
const { registerContent, shouldBlockDismiss } =
useDismissGuard<React.ElementRef<typeof DialogPrimitive.Content>>(ref);

return (
<DialogPortal>
<DialogOverlay className={overlayClassName} />
<DialogPrimitive.Content
ref={(node) => {
contentRef.current = node;
if (typeof ref === "function") ref(node);
else if (ref) ref.current = node;
}}
ref={registerContent}
onInteractOutside={(event) => {
onInteractOutside?.(event);
if (event.defaultPrevented) return;
// Focus-outside dismissals would read a snapshot left over from the
// last pointerdown, however long ago — the guard is pointer-only.
if (event.detail.originalEvent.type !== "pointerdown") return;
if (layerWasAboveRef.current) event.preventDefault();
if (shouldBlockDismiss(event)) event.preventDefault();
}}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border p-6 shadow-2xl duration-200 rounded-2xl",
Expand Down
74 changes: 74 additions & 0 deletions src/components/ui/useDismissGuard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import * as React from "react";

/**
* Keeps a Radix dialog open when the click that dismissed a layer *above* it
* lands on the dialog's own overlay.
*
* While a layer that disables outside pointer events is open over a dialog — a
* popper (Select/Popover/DropdownMenu) or a stacked dialog — Radix sets
* `pointer-events: none` on the dialog's content. A click aimed at the panel
* therefore misses the content entirely and hits the full-viewport overlay
* behind it, which Radix registers as the dialog's own dismiss affordance.
*
* Radix also defers a dialog's outside-click dismissal to a one-time document
* `click` listener. By the time it runs, the upper layer has already closed and
* handed pointer events back, so the check no longer sees anything above and
* dismisses the dialog. So "was something above us" has to be snapshotted at
* pointerdown capture time, ahead of every Radix handler.
*/

/** The subset of `Document` the probe reads, so the policy stays unit-testable. */
export interface LayerProbe {
querySelector(selectors: string): Element | null;
querySelectorAll(selectors: string): ArrayLike<Element>;
}

export function hasLayerAbove(content: HTMLElement | null, doc: LayerProbe): boolean {
if (!content) return false;
// Radix only writes `none` here when a higher layer disabled outside pointer
// events — the exact condition that misroutes the click onto the overlay.
if (content.style.pointerEvents === "none") return true;
if (doc.querySelector("[data-radix-popper-content-wrapper]")) return true;
// Later-mounted portals stack on top, so the last open dialog in DOM order is
// the topmost one.
const openDialogs = doc.querySelectorAll('[role="dialog"][data-state="open"]');
return openDialogs.length > 0 && openDialogs[openDialogs.length - 1] !== content;
}

interface OutsideEvent {
detail: { originalEvent: Event };
}

export function useDismissGuard<T extends HTMLElement>(forwardedRef?: React.ForwardedRef<T>) {
const contentRef = React.useRef<T | null>(null);
const layerWasAboveRef = React.useRef(false);

React.useEffect(() => {
const snapshotLayersAbove = () => {
layerWasAboveRef.current = hasLayerAbove(contentRef.current, document);
};
document.addEventListener("pointerdown", snapshotLayersAbove, { capture: true });
return () => {
document.removeEventListener("pointerdown", snapshotLayersAbove, { capture: true });
};
}, []);

/** Ref for the dialog content, composed with the caller's forwarded ref. */
const registerContent = React.useCallback(
(node: T | null) => {
contentRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef]
);

const shouldBlockDismiss = React.useCallback((event: OutsideEvent) => {
// Focus-outside dismissals would read a snapshot left over from the last
// pointerdown, however long ago — the guard is pointer-only.
if (event.detail.originalEvent.type !== "pointerdown") return false;
return layerWasAboveRef.current;
}, []);

return { registerContent, shouldBlockDismiss };
}
60 changes: 60 additions & 0 deletions test/components/useDismissGuard.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
const test = require("node:test");
const assert = require("node:assert/strict");

async function loadGuard() {
const mod = await import("../../src/components/ui/useDismissGuard.ts");
return mod.hasLayerAbove ?? mod.default.hasLayerAbove;
}

// A stand-in for the dialog's own content node. Radix writes `pointer-events`
// inline on it, so the style bag is all the probe reads.
function contentNode(pointerEvents) {
return { style: { pointerEvents } };
}

function probe({ popper = false, openDialogs = [] } = {}) {
return {
querySelector: (sel) => (sel === "[data-radix-popper-content-wrapper]" && popper ? {} : null),
querySelectorAll: () => openDialogs,
};
}

test("nothing above the dialog is not a layer", async () => {
const hasLayerAbove = await loadGuard();
const content = contentNode("auto");
assert.equal(hasLayerAbove(content, probe({ openDialogs: [content] })), false);
});

test("an inert content node means a layer above disabled its pointer events", async () => {
const hasLayerAbove = await loadGuard();
// This is the Select-open case: Radix sets `pointer-events: none` on the
// dialog content, so the click that dismisses the Select lands on the
// dialog's own overlay instead of on the panel the user aimed at.
const content = contentNode("none");
assert.equal(hasLayerAbove(content, probe({ openDialogs: [content] })), true);
});

test("a mounted radix popper is a layer above", async () => {
const hasLayerAbove = await loadGuard();
const content = contentNode("auto");
assert.equal(hasLayerAbove(content, probe({ popper: true, openDialogs: [content] })), true);
});

test("a dialog opened later stacks above this one", async () => {
const hasLayerAbove = await loadGuard();
const content = contentNode("auto");
const stacked = contentNode("auto");
assert.equal(hasLayerAbove(content, probe({ openDialogs: [content, stacked] })), true);
});

test("the topmost open dialog has nothing above it", async () => {
const hasLayerAbove = await loadGuard();
const below = contentNode("auto");
const content = contentNode("auto");
assert.equal(hasLayerAbove(content, probe({ openDialogs: [below, content] })), false);
});

test("an unmounted content node reports no layer above", async () => {
const hasLayerAbove = await loadGuard();
assert.equal(hasLayerAbove(null, probe()), false);
});
Loading