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
11 changes: 11 additions & 0 deletions .changeset/dialog-outside-click-keeps-form-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@executor-js/react": patch
---

**A click outside a dialog or sheet no longer discards the form inside it**

Radix dismisses an overlay surface on any un-prevented outside interaction, and `DialogContent` and `SheetContent` only prevented that for clicks landing in a portaled combobox or select popup. Every other outside click fell through to dismissal, so a stray click on the page behind a form — after switching windows to copy an ID, for example — closed the surface and destroyed what the user had typed. These surfaces unmount their state on close by design, so nothing was recoverable.

The default is now the opposite: an outside interaction keeps the surface open. Escape and the close button are unchanged and still close it. `DialogContent` and `SheetContent` take a new `dismissOnOutsideClick` prop for surfaces with nothing to lose — confirmations, pickers, and read-only panels — and the portaled-popup guard still applies there, so choosing a combobox option never dismisses.

`CommandDialog` sets `dismissOnOutsideClick` on by default, because a command palette holds only a search string and clicking away is the expected way to leave it.
6 changes: 4 additions & 2 deletions apps/cloud/src/routes/app/billing_.plans.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -401,7 +401,8 @@ function EnterpriseContactDialog() {
Contact us
</Button>
</DialogTrigger>
<DialogContent>
{/* Static contact details only: clicking away dismisses it. */}
<DialogContent dismissOnOutsideClick>
<DialogHeader>
<DialogTitle>Talk to us about Enterprise</DialogTitle>
<DialogDescription>
Expand Down Expand Up @@ -449,7 +450,8 @@ function SlackContactCta() {
<span aria-hidden>→</span>
</Button>
</DialogTrigger>
<DialogContent>
{/* Static contact details only: clicking away dismisses it. */}
<DialogContent dismissOnOutsideClick>
<DialogHeader>
<DialogTitle>Get in touch on Slack</DialogTitle>
<DialogDescription>
Expand Down
3 changes: 2 additions & 1 deletion apps/cloud/src/web/components/support-slot.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,8 @@ export function SupportSlot() {
<HelpIcon className="size-3.5 text-muted-foreground" />
Get support
</Button>
<DialogContent className="sm:max-w-[440px]">
{/* Static links only: clicking away dismisses it. */}
<DialogContent dismissOnOutsideClick className="sm:max-w-[440px]">
<DialogHeader>
<DialogTitle className="font-display text-xl">Get support</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
Expand Down
9 changes: 3 additions & 6 deletions packages/react/src/components/add-account-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2357,15 +2357,12 @@ function AddAccountModalView(props: AddAccountModalProps) {
// Non-modal for the same reason as the health-check editor sheet: a modal
// dialog's react-remove-scroll locks the wheel to the dialog subtree, so
// the operation combobox's PORTALED popup (and the modal body while it is
// open) cannot scroll. The overlay still dims and outside-click still
// closes; the portaled-popup guard in DialogContent keeps option clicks
// from dismissing.
// open) cannot scroll. The overlay still dims, so the dialog keeps its
// modal look. This form holds credentials and a half-built auth method, so
// it takes DialogContent's default: an outside click does not dismiss it.
<Dialog open={open} onOpenChange={onOpenChange} modal={false}>
<DialogContent
forceOverlay
onInteractOutside={
oauthClientHandoff?.action === "reconnect" ? (event) => event.preventDefault() : undefined
}
className={cn(
"max-h-[85vh] overflow-x-hidden overflow-y-auto",
(addingMethod && createCustomMethod) || oauthRegistering || oauthEditing
Expand Down
6 changes: 6 additions & 0 deletions packages/react/src/components/command.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,17 @@ function CommandDialog({
children,
className,
showCloseButton = true,
dismissOnOutsideClick = true,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string;
description?: string;
className?: string;
showCloseButton?: boolean;
/** A command palette holds nothing but a search string, and clicking away is
* the expected way to leave it, so this stays on — unlike `DialogContent`,
* whose default protects form state. */
dismissOnOutsideClick?: boolean;
}) {
return (
<Dialog {...props}>
Expand All @@ -42,6 +47,7 @@ function CommandDialog({
<DialogContent
className={cn("overflow-hidden p-0", className)}
showCloseButton={showCloseButton}
dismissOnOutsideClick={dismissOnOutsideClick}
>
<Command className="**:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
Expand Down
27 changes: 15 additions & 12 deletions packages/react/src/components/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as DialogPrimitive } from "radix-ui";

import { applyOutsideDismissPolicy } from "../lib/outside-dismiss";
import { cn } from "../lib/utils";
import { Button } from "./button";

Expand Down Expand Up @@ -37,25 +38,26 @@ function DialogOverlay({
);
}

// base-ui popups (combobox/select) portal their list OUTSIDE the dialog content,
// so clicking an option reads as an interaction outside the dialog and would
// dismiss it before the selection lands. Keep the dialog open for interactions
// that originate inside such a popup.
const PORTALED_POPUP_SELECTOR = "[data-slot='combobox-content'],[data-slot='select-content']";

function DialogContent({
className,
children,
showCloseButton = true,
dismissOnOutsideClick = false,
onInteractOutside,
onPointerDownOutside,
forceOverlay = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
showCloseButton?: boolean;
/** Let a click outside the dialog close it. Off by default: a stray click on
* the page behind a form must not discard what the user typed. Turn it on
* for a dialog with nothing to lose — a confirmation, a picker, a read-only
* panel. Escape and the close button close either way. */
dismissOnOutsideClick?: boolean;
/** Pair with `<Dialog modal={false}>`: Radix renders no overlay in non-modal
* mode, so this renders a plain dim layer instead. It still eats outside
* clicks (which dismiss via onInteractOutside), so the dialog keeps its
* modal look while the wheel stays free for portaled popups. */
* clicks, so the dialog keeps its modal look while the wheel stays free for
* portaled popups. */
forceOverlay?: boolean;
}) {
return (
Expand All @@ -66,12 +68,13 @@ function DialogContent({
) : null}
<DialogPrimitive.Content
data-slot="dialog-content"
onPointerDownOutside={(event) => {
onPointerDownOutside?.(event);
applyOutsideDismissPolicy(event, dismissOnOutsideClick);
}}
onInteractOutside={(event) => {
const target = event.detail.originalEvent.target;
if (target instanceof Element && target.closest(PORTALED_POPUP_SELECTOR)) {
event.preventDefault();
}
onInteractOutside?.(event);
applyOutsideDismissPolicy(event, dismissOnOutsideClick);
}}
className={cn(
"fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg duration-200 outline-none data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 sm:max-w-lg",
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/components/remove-oauth-app-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,8 @@ export function RemoveOAuthAppDialog(props: {
const inUse = connections.length > 0;
return (
<Dialog open onOpenChange={(open: boolean) => (open ? undefined : props.onClose())}>
<DialogContent>
{/* A confirmation with nothing to lose: clicking away cancels it. */}
<DialogContent dismissOnOutsideClick>
<DialogHeader>
<DialogTitle>Remove {String(client.slug)}?</DialogTitle>
<DialogDescription>
Expand Down
23 changes: 13 additions & 10 deletions packages/react/src/components/sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as React from "react";
import { XIcon } from "lucide-react";
import { Dialog as SheetPrimitive } from "radix-ui";

import { applyOutsideDismissPolicy } from "../lib/outside-dismiss";
import { cn } from "../lib/utils";

function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
Expand Down Expand Up @@ -38,34 +39,36 @@ function SheetOverlay({
);
}

// base-ui popups (combobox/select) portal their list OUTSIDE the dialog content,
// so clicking an option reads as an interaction outside the sheet and would
// dismiss it before the selection lands. Keep the sheet open for interactions
// that originate inside such a popup.
const PORTALED_POPUP_SELECTOR = "[data-slot='combobox-content'],[data-slot='select-content']";

function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
dismissOnOutsideClick = false,
onInteractOutside,
onPointerDownOutside,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left";
showCloseButton?: boolean;
/** Let a click outside the sheet close it. Off by default: a stray click on
* the page behind a form must not discard what the user typed. Turn it on
* for a sheet with nothing to lose — navigation, a picker, a read-only
* panel. Escape and the close button close either way. */
dismissOnOutsideClick?: boolean;
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
onPointerDownOutside={(event) => {
onPointerDownOutside?.(event);
applyOutsideDismissPolicy(event, dismissOnOutsideClick);
}}
onInteractOutside={(event) => {
const target = event.detail.originalEvent.target;
if (target instanceof Element && target.closest(PORTALED_POPUP_SELECTOR)) {
event.preventDefault();
}
onInteractOutside?.(event);
applyOutsideDismissPolicy(event, dismissOnOutsideClick);
}}
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
Expand Down
3 changes: 3 additions & 0 deletions packages/react/src/components/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,9 @@ function Sidebar({
return (
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
// Navigation only. Tapping the page behind it is the normal way to
// put the mobile nav away, and nothing is lost.
dismissOnOutsideClick
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
Expand Down
77 changes: 77 additions & 0 deletions packages/react/src/lib/outside-dismiss.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "@effect/vitest";

import {
applyOutsideDismissPolicy,
dismissesOnOutsideInteraction,
PORTALED_POPUP_SELECTOR,
} from "./outside-dismiss";

/**
* The property under test: a dialog or sheet keeps what the user typed.
*
* Radix closes an overlay surface whenever an outside interaction reaches it
* un-prevented, so "does not dismiss" is the thing that has to be asserted, not
* assumed. Escape is Radix's own `onEscapeKeyDown` path and never passes
* through here, which is why no case below can close a surface with a key.
*/
describe("dismissesOnOutsideInteraction", () => {
it("keeps the surface open by default", () => {
expect(
dismissesOnOutsideInteraction({
dismissOnOutsideClick: false,
insidePortaledPopup: false,
}),
).toBe(false);
});

it("closes the surface when it opts in", () => {
expect(
dismissesOnOutsideInteraction({ dismissOnOutsideClick: true, insidePortaledPopup: false }),
).toBe(true);
});

it("never closes on a click inside a portaled popup, even when it opts in", () => {
// A combobox or select renders its list outside the surface, so choosing an
// option arrives as an outside interaction. Dismissing there would drop the
// selection before it lands.
expect(
dismissesOnOutsideInteraction({ dismissOnOutsideClick: true, insidePortaledPopup: true }),
).toBe(false);
});
});

/** A stand-in for the event Radix dispatches, recording whether it was blocked. */
const outsideEvent = () => {
let prevented = false;
return {
event: {
detail: { originalEvent: { target: { nodeName: "DIV" } } },
preventDefault: () => {
prevented = true;
},
},
prevented: () => prevented,
};
};

describe("applyOutsideDismissPolicy", () => {
it("blocks a plain outside click by default", () => {
const outside = outsideEvent();
applyOutsideDismissPolicy(outside.event, false);
expect(outside.prevented()).toBe(true);
});

it("lets a plain outside click through when the surface opts in", () => {
const outside = outsideEvent();
applyOutsideDismissPolicy(outside.event, true);
expect(outside.prevented()).toBe(false);
});
});

describe("PORTALED_POPUP_SELECTOR", () => {
it("covers both popup slots that portal out of a surface", () => {
expect(PORTALED_POPUP_SELECTOR).toBe(
"[data-slot='combobox-content'],[data-slot='select-content']",
);
});
});
53 changes: 53 additions & 0 deletions packages/react/src/lib/outside-dismiss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Outside-interaction policy shared by `DialogContent` and `SheetContent`.
*
* Radix dismisses an overlay surface when an outside interaction is not
* default-prevented. That loses whatever the user typed, and a stray click on
* the page behind a form is easy to make. So the default here is the opposite
* of Radix's: an outside interaction keeps the surface open. Escape and the
* close button are unaffected — they still close.
*
* A surface with nothing to lose (a confirmation, a picker, a read-only panel)
* opts back in with `dismissOnOutsideClick`.
*/

/** base-ui popups (combobox/select) portal their list OUTSIDE the surface, so a
* click on an option reads as an interaction outside it. Such a click must
* never dismiss, even when the surface opts in. */
export const PORTALED_POPUP_SELECTOR =
"[data-slot='combobox-content'],[data-slot='select-content']";

/** True when the interaction started inside a popup this surface portals out. */
export const isInsidePortaledPopup = (target: unknown): boolean =>
typeof Element !== "undefined" &&
target instanceof Element &&
target.closest(PORTALED_POPUP_SELECTOR) !== null;

/**
* Whether an outside interaction should close the surface.
*
* Pure so the decision is testable without a DOM: the caller does the element
* lookup and passes the answer in.
*/
export const dismissesOnOutsideInteraction = (input: {
readonly dismissOnOutsideClick: boolean;
readonly insidePortaledPopup: boolean;
}): boolean => input.dismissOnOutsideClick && !input.insidePortaledPopup;

/** The shape Radix hands to `onInteractOutside` and `onPointerDownOutside`. */
type OutsideInteractionEvent = {
readonly detail: { readonly originalEvent: { readonly target: unknown } };
readonly preventDefault: () => void;
};

/** Apply the policy to a Radix outside-interaction event. */
export const applyOutsideDismissPolicy = (
event: OutsideInteractionEvent,
dismissOnOutsideClick: boolean,
): void => {
const dismisses = dismissesOnOutsideInteraction({
dismissOnOutsideClick,
insidePortaledPopup: isInsidePortaledPopup(event.detail.originalEvent.target),
});
if (!dismisses) event.preventDefault();
};
3 changes: 2 additions & 1 deletion packages/react/src/pages/admin-users.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -670,7 +670,8 @@ export function AdminUsersPage() {
})}

<Sheet open={selected !== null} onOpenChange={(open) => !open && setSelected(null)}>
<SheetContent className="w-full gap-0 p-0 sm:max-w-xl">
{/* A read-only detail panel: clicking away closes it. */}
<SheetContent dismissOnOutsideClick className="w-full gap-0 p-0 sm:max-w-xl">
{selected && (
<>
<SheetHeader className="border-b border-border">
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/pages/api-keys.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -500,7 +500,8 @@ function OrgApiKeysSectionBody() {
if (!open) setConfirmRevoke(null);
}}
>
<DialogContent className="sm:max-w-[480px]">
{/* A confirmation with nothing to lose: clicking away cancels it. */}
<DialogContent dismissOnOutsideClick className="sm:max-w-[480px]">
<DialogHeader>
<DialogTitle className="font-display text-xl">Revoke organization key</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
Expand Down
3 changes: 2 additions & 1 deletion packages/react/src/pages/org.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,8 @@ function UpgradeDialog(props: {
}) {
return (
<Dialog open={props.open} onOpenChange={props.onOpenChange}>
<DialogContent className="sm:max-w-[400px]">
{/* Informational only: nothing to lose, so clicking away dismisses it. */}
<DialogContent dismissOnOutsideClick className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle className="font-display text-xl">You are at your member limit</DialogTitle>
<DialogDescription className="text-sm leading-relaxed">
Expand Down
Loading