Skip to content

feat(ui): close dialogs from a corner button, not an outside click - #295

Merged
quickbeard merged 1 commit into
mainfrom
dialog-close-button
Aug 22, 2026
Merged

quickbeard merged 1 commit into
mainfrom
dialog-close-button

Conversation

@quickbeard

Copy link
Copy Markdown
Owner

Dialogs closed when you clicked beside them, and none of them offered a close button. Both are fixed here.

Dropping the auto-close

A click anywhere in the dimmed area around a dialog dismissed it instantly, taking whatever it held with it — a half-filled sandbox form, a provider mid-connect, a folder being picked.

It came from two independent mechanisms, and both had to go:

  • packages/ui/src/context/dialog.tsx — the backdrop's explicit onClick, removed.
  • Kobalte's own dismissable layer, which closes on any pointerdown outside the content regardless of the overlay. Blocked with onInteractOutside={(e) => e.preventDefault()} on all three dialog contents (v1 Dialog, DialogV2, ImagePreview).

Removing only the first would have looked right and changed nothing.

Escape still closes everything it did — that path is the context's own window listener, untouched, as is the persistent guard on both.

The corner close button

Both Dialog primitives now render the Kobalte.CloseButton themselves, as the last child of their content and pinned to its top-right corner — at the same coordinates the header button used to land on, so dialogs that already had one look unchanged, while header-less ones (settings, release notes, the command palettes) get one at all. Keeping it inside Kobalte.Content keeps it inside the focus trap and the modal's pointer-events layer; positioning it outside would have made it untabbable and aria-hidden.

Headers reserve the corner in padding-right (v1 52px, v2 42px), and both containers take a min-height so a short fit dialog cannot clip the button.

That let the hideClose opt-outs go from every call site that had one — the sandbox, WSL, server, session-delete and manage-models dialogs were all hiding theirs. In v1 the button also no longer competes with the action slot, whose branch used to replace it outright: manage-models had a "Connect provider" button and no way out.

Two deliberate exceptions:

  • The provider configure gate gets hideClose. The dialog stack marks it persistent and refuses every user-driven close while no provider is configured, so a button there would be dead on click.
  • The two search-first palettes open onto a full-width input under the corner. They keep the button; the space is reserved instead (padding-right on the v2 palette's search row, pr-10 on the v1 file picker's).

Verification

Drove headless Chrome over CDP against the Storybook stories, v1 and v2, headed and header-less:

case open before after backdrop click after Escape after close-button click
all four 1 1 0 0

Screenshots of each checked the placement, and caught two bugs that are fixed here: a short fit dialog clipping its own close button against the container's overflow: hidden, and a CSS specificity bug where the base rule outranked the header-less inset override.

bun typecheck passes across all 33 packages; bun test is clean in packages/ui (14) and packages/app (690).

Fork bookkeeping

Three rows in DIVERGENCES.md (the context, the primitives, the call sites) and a user-facing entry in CHANGELOG.md under Desktop / Web UI.

🤖 Generated with Claude Code

A click anywhere in the dimmed area around a dialog dismissed it, taking
whatever it held with it: a half-filled sandbox form, a provider mid-connect,
a folder being picked. That came from two independent mechanisms, so both go —
the overlay's own onClick in the dialog context, and Kobalte's dismissable
layer, which closes on outside pointerdown regardless of the overlay and needs
onInteractOutside prevented on the content.

In exchange every dialog now has a close button. Both Dialog primitives render
the Kobalte.CloseButton themselves, as the last child of their content and
pinned to its top-right corner at the same coordinates the header button used
to land on — so headed dialogs look unchanged, while header-less ones (settings,
release notes, the command palettes) get one at all. Keeping it inside
Kobalte.Content keeps it inside the focus trap and the modal's pointer-events
layer. Headers reserve the corner in padding-right, and the containers take a
min-height so a short fit dialog cannot clip the button.

That let the hideClose opt-outs go from every call site that had one, and in v1
the button no longer competes with the `action` slot, whose branch used to
replace it outright — manage-models had a "Connect provider" button and no way
out. Two dialogs keep an exception: the configure gate, which the dialog stack
also marks persistent so a button there would be inert, and the two search-first
palettes, which reserve the corner rather than let the button sit on the input.

Escape still closes everything it did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@quickbeard
quickbeard force-pushed the dialog-close-button branch from 4e94b55 to 82769aa Compare August 22, 2026 08:57
@quickbeard
quickbeard merged commit c475d7b into main Aug 22, 2026
0 of 5 checks passed
@quickbeard
quickbeard deleted the dialog-close-button branch August 22, 2026 09:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant