From 82769aa18f7bbc4ff2f29207d2187dcaa754ad53 Mon Sep 17 00:00:00 2001 From: Quickbeard Date: Sat, 22 Aug 2026 15:51:29 +0700 Subject: [PATCH] feat(ui): close dialogs from a corner button, not an outside click MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 9 +++ DIVERGENCES.md | 3 + .../components/dialog-command-palette-v2.css | 4 +- .../components/dialog-connect-provider.tsx | 2 +- .../src/components/dialog-manage-models.tsx | 2 +- .../app/src/components/dialog-select-file.tsx | 2 +- .../dialog-select-model-unpaid-v2.tsx | 2 +- .../components/gateway/dialog-configure.tsx | 2 +- .../src/components/session-actions-menu.tsx | 2 +- .../settings-v2/dialog-server-v2.tsx | 2 +- .../app/src/sandbox/dialog-add-default.tsx | 2 +- packages/app/src/sandbox/dialog-add.tsx | 2 +- packages/app/src/sandbox/dialog-delete.tsx | 2 +- packages/app/src/sandbox/dialog-mounts.tsx | 4 +- packages/app/src/wsl/dialog-add-server.tsx | 2 +- packages/app/src/wsl/dialog-delete.tsx | 2 +- packages/ui/src/components/dialog.css | 29 ++++++- packages/ui/src/components/dialog.stories.tsx | 25 ++++++ packages/ui/src/components/dialog.tsx | 36 +++++---- packages/ui/src/components/image-preview.tsx | 7 +- packages/ui/src/context/dialog.tsx | 19 ++--- packages/ui/src/v2/components/dialog-v2.css | 48 +++++++----- .../src/v2/components/dialog-v2.stories.tsx | 26 ++++++- packages/ui/src/v2/components/dialog-v2.tsx | 77 +++++++++++-------- 24 files changed, 216 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 53234070e3..d21973803e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -284,6 +284,15 @@ already in use` and no way forward short of finding the file in a temporary ### Desktop / Web UI +- **Dialogs no longer close when you click beside them, and every one has a + close button** — a stray click anywhere in the dimmed area around a dialog + dismissed it instantly, discarding whatever it held: a half-filled sandbox + form, a provider being connected, a folder being picked. Clicking outside now + does nothing. In exchange every dialog carries an × in its top-right corner — + the several that had been hiding theirs included — so there is always a + visible way out; Escape still works everywhere it did. The one exception is + the provider setup screen the app shows when no provider is configured, which + has never been dismissable. - **The Work tabs stay out of the way until you use sandboxes** — every desktop install showed a **Code** / **Work** switch above the projects list, even though Work was empty for anyone who had never made a sandbox: switching to it diff --git a/DIVERGENCES.md b/DIVERGENCES.md index 27144ac401..8bcc132c54 100644 --- a/DIVERGENCES.md +++ b/DIVERGENCES.md @@ -284,6 +284,9 @@ from the squash message first, or use a plain merge. | Legacy prompt input removed: deleted `packages/app/src/components/prompt-input.tsx` and its now-orphaned submodules `packages/app/src/components/prompt-input/{attachments,files,paste,transient-state}.ts`, `{context-items,drag-overlay,image-attachments,slash-popover}.tsx`, `image-attachments.css`, `attachments.test.ts`, `packages/app/test-browser/{prompt-attachments,prompt-transient-state,prompt-at-options}.test.ts` (plus the fork-only `at-options{,.test}.ts`); edits in `packages/app/src/pages/session.tsx`, `packages/app/src/pages/session/composer/{session-composer-region-controller.ts,todo-panel-motion.stories.tsx}` | The fork hardcodes `newLayoutDesigns` to `true` (see the settings.tsx row), so the legacy `PromptInput` — the `fallback` of session.tsx's `` — could never render. session.tsx now creates the V2 controller unconditionally and always mounts `PromptInputV2Composer` (no `Show`); `session-composer-region-controller.ts` imports `PromptInputState` from `prompt-input/contracts` (where it is defined) instead of the deleted barrel; the todo-panel-motion story fills its composer-region `promptInput` slot with a static styled placeholder instead of the legacy component (its unused `controls` mock removed). Kept: `prompt-input/{contracts,history,history-store,placeholder,submit,editor-dom,build-request-parts,submission-state}.ts` and their tests — the V2 composer path still uses them. | Dead code: retired with the legacy layout, kept compiling for months, and PR #56-style fixes had to be applied to it for nothing. | Keep deleted — `git rm` the upstream side of modify/delete conflicts on these files, and delete any new module upstream adds under `prompt-input/` that only the legacy `PromptInput` imports. Take upstream changes to the kept shared modules. If upstream restructures session.tsx's composer block, re-apply "always render the V2 composer" and keep the legacy fallback out; if upstream rewires the story's input slot, keep our placeholder. If the fork ever wants the legacy layout back, restore from upstream, not from history. | | Work side is opt-in: `packages/app/src/context/settings.tsx`, `packages/app/src/components/settings-v2/general.tsx`, `packages/app/src/i18n/{en,es}.ts` | The Work side and the Code/Work tab strip render only when `workSideAvailable()` (fork-only `packages/app/src/workspace/side.ts`) says so: sandbox-capable platform AND (a sandbox exists OR the new `general.showWork` preference is on). settings.tsx gains `showWork` (default `false`) with its accessor/setter beside `showCustomAgents`; settings-v2's Advanced section gains a "Show Work" switch, wrapped in `` so non-sandbox builds never offer it; i18n gains `settings.general.row.showWork.{title,description}`. The fork-only wiring is in `workspace/tabs.tsx`: a `useWorkAvailable()` hook feeds both `useWorkspaceSide()`'s `effectiveSide` call and `WorkspaceTabs`'s `` (which previously gated on `platform.sandboxes`), so the side and its strip can never disagree — a hidden strip with Work selected would strand the user on sandbox servers with no way back. An undefined sandbox list means "not loaded", not "none", and keeps Work (the same race the Code/Work split row calls out); the main process answers `sandbox-get-state` synchronously, so that window is sub-frame. Tests: `packages/app/test-browser/workspace-side.test.ts` covers the four `workSideAvailable` branches. | Sandboxes are a heavyweight opt-in feature (a VM per sandbox), but every desktop profile paid for them in the sidebar: a Code/Work strip with a Work side that had nothing in it and offered to build one. Hiding both until a sandbox exists makes the strip appear exactly when there are two sides worth picking between; the preference is the discoverable way in for someone who wants Work before creating anything, and Settings → Servers → + → Add sandbox is the other. Owning a sandbox shows Work whatever the preference says. | Keep the `workSideAvailable` gate on both `useWorkspaceSide` and `WorkspaceTabs` — gating only one reintroduces the stranded-side bug. Take upstream's surrounding settings/i18n changes. If upstream adds its own sandbox-visibility preference, prefer theirs and drop `showWork`. Never collapse on an unloaded sandbox list. | +| `packages/ui/src/context/dialog.tsx` (again) | The `Kobalte.Overlay` lost its `onClick`, so a backdrop click no longer closes the top dialog. Escape (the window `keydown` listener) and `onOpenChange` (the close button) are untouched, as is the `persistent` guard on both. | A stray click in the dimmed area around a dialog dismissed it and discarded whatever it held — a half-filled sandbox form, a provider mid-connect. Dismissal is now always deliberate: the corner close button or Escape. | Keep the overlay handler-less. If upstream adds behaviour to the overlay, take it minus the close. Pairs with the `onInteractOutside` guards on the three dialog contents below — removing the handler alone is not enough, Kobalte's own dismissable layer closes on outside pointerdown too. | +| Dialogs close on a button, never on an outside click: `packages/ui/src/components/dialog.tsx`, `packages/ui/src/components/dialog.css`, `packages/ui/src/v2/components/dialog-v2.tsx`, `packages/ui/src/v2/components/dialog-v2.css`, `packages/ui/src/components/image-preview.tsx`, plus the `*.stories.tsx` beside the two dialogs | All three `Kobalte.Content`s take `onInteractOutside={(e) => e.preventDefault()}`, which is what actually blocks Kobalte's dismissable layer (the context row above only removes the explicit overlay click). Both `Dialog`s gained `closeLabel?`/`hideClose?` and now render the `Kobalte.CloseButton` themselves, as the last child of their content, absolutely positioned at the content's top-right — v1 at 16px/20px (12px/12px when `data-no-header`), v2 at 14px/14px, both at `z-index: 30` so they clear the settings tab header's sticky `z-index: 10`. The button was removed from v2's `DialogHeader` (which loses its `hideClose`/`closeLabel` props and its `data-hide-close` attribute) and from v1's header `Switch`, whose `action` branch used to *replace* it; `action` now simply renders. Headers reserve the corner in `padding-right` (v1 52px, v2 42px) and v1's title takes `flex: 1`; both containers take a `min-height` (v1 52px, v2 48px) so a short `fit` dialog cannot clip the button. `aria-label` defaults to `ui.common.close` through `useI18n` — the first v2 component to use the UI i18n context, which has an English fallback. | Every dialog needed a visible way out once the backdrop stopped being one, including the dozen whose call sites passed `hideClose`. Pinning the button to the content corner rather than laying it out in the header is what lets header-less dialogs (settings, release notes, the command palettes) have one at all; keeping it inside `Kobalte.Content` keeps it inside the focus trap and the modal's pointer-events layer. | Keep the corner button and the `onInteractOutside` guards. Upstream owns the header/body/footer layout — take its changes and re-apply the `padding-right` reserve and the close button on top. If upstream adds its own close button to `DialogHeader`, drop ours from `Dialog` rather than shipping two. | +| Dialog close buttons at the call sites: `packages/app/src/sandbox/{dialog-add,dialog-add-default,dialog-delete,dialog-mounts}.tsx`, `packages/app/src/wsl/{dialog-add-server,dialog-delete}.tsx`, `packages/app/src/components/{dialog-manage-models,dialog-connect-provider,dialog-select-model-unpaid-v2,session-actions-menu,settings-v2/dialog-server-v2}.tsx`, `packages/app/src/components/gateway/dialog-configure.tsx`, `packages/app/src/components/dialog-select-file.tsx`, `packages/app/src/components/dialog-command-palette-v2.css` | Dropped every `hideClose`/`closeLabel` prop from `DialogHeader` (the button moved to `Dialog`, and hiding it is no longer wanted anywhere it was hidden). `dialog-configure` passes `hideClose` on its `Dialog` instead — it is the configure gate, which the dialog stack also marks `persistent`, so a close button there would be inert. The two search-first dialogs reserve the corner the button now occupies: `dialog-select-file` via `[&_[data-slot=list-search-wrapper]]:pr-10`, the v2 command palette via `padding-right` on `.command-palette-v2-search`. | Fork-only sandbox/WSL/gateway dialogs aside, these are upstream files whose only change is a removed prop. | Take upstream's version of these call sites and re-drop `hideClose`. The `hideClose` on `dialog-configure` is fork-only and must stay. | ## Known rebrand gaps diff --git a/packages/app/src/components/dialog-command-palette-v2.css b/packages/app/src/components/dialog-command-palette-v2.css index a4019928aa..acc1789fb3 100644 --- a/packages/app/src/components/dialog-command-palette-v2.css +++ b/packages/app/src/components/dialog-command-palette-v2.css @@ -30,7 +30,9 @@ .command-palette-v2-search { flex-shrink: 0; - padding: 6px; + /* Right padding keeps the search field clear of the dialog's corner close + button, which floats over the content at 14px from the top and right. */ + padding: 6px 40px 6px 6px; } .command-palette-v2-search [data-component="text-input-v2"] { diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 4c58857249..c4238bb795 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -121,7 +121,7 @@ export const DialogConnectProvider: Component<{ containerClass="!h-[min(calc(100vh_-_16px),512px)] !w-[min(calc(100vw_-_16px),640px)]" class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]" > - + {language.t("command.provider.connect")}} diff --git a/packages/app/src/components/dialog-manage-models.tsx b/packages/app/src/components/dialog-manage-models.tsx index d6c5e99186..6f7bf8b95a 100644 --- a/packages/app/src/components/dialog-manage-models.tsx +++ b/packages/app/src/components/dialog-manage-models.tsx @@ -155,7 +155,7 @@ export const DialogManageModelsV2: Component = () => { return ( - + boolean; onOpenFile?: } return ( - + = (pro containerClass="!h-auto max-h-[calc(100vh_-_16px)] !w-[min(calc(100vw_-_16px),640px)]" class="[font-family:var(--v2-font-family-sans)] [&_[data-slot=dialog-header]]:!px-5 [&_[data-slot=dialog-header-title]]:!text-[15px] [&_[data-slot=dialog-header-title]]:!tracking-[-0.13px]" > - + {language.t("dialog.model.select.title")} diff --git a/packages/app/src/components/gateway/dialog-configure.tsx b/packages/app/src/components/gateway/dialog-configure.tsx index e6ac311a7c..ee89ffc180 100644 --- a/packages/app/src/components/gateway/dialog-configure.tsx +++ b/packages/app/src/components/gateway/dialog-configure.tsx @@ -173,7 +173,7 @@ export function DialogConfigure() { } return ( - +
diff --git a/packages/app/src/components/session-actions-menu.tsx b/packages/app/src/components/session-actions-menu.tsx index 68c047adb6..af466fd4f8 100644 --- a/packages/app/src/components/session-actions-menu.tsx +++ b/packages/app/src/components/session-actions-menu.tsx @@ -182,7 +182,7 @@ export function DialogDeleteSession(props: { title: string; onConfirm: () => unk if (settings.general.newLayoutDesigns()) return ( - + - + {title()} diff --git a/packages/app/src/sandbox/dialog-add-default.tsx b/packages/app/src/sandbox/dialog-add-default.tsx index 27c9018bcd..03d63a1749 100644 --- a/packages/app/src/sandbox/dialog-add-default.tsx +++ b/packages/app/src/sandbox/dialog-add-default.tsx @@ -89,7 +89,7 @@ export function DialogAddDefaultSandbox(props: { onCreated?: (config: SandboxCon return ( - + - + {/* As a description under the title rather than a line in the body: it explains the whole dialog, not the field it happens to sit above, and DialogTitleGroup ties it to the dialog for screen diff --git a/packages/app/src/sandbox/dialog-delete.tsx b/packages/app/src/sandbox/dialog-delete.tsx index 8be600df64..9e0c36cd9c 100644 --- a/packages/app/src/sandbox/dialog-delete.tsx +++ b/packages/app/src/sandbox/dialog-delete.tsx @@ -15,7 +15,7 @@ export function DialogDeleteSandbox(props: { name: string; onConfirm: () => void return ( - + {language.t("sandbox.delete.title")} diff --git a/packages/app/src/sandbox/dialog-mounts.tsx b/packages/app/src/sandbox/dialog-mounts.tsx index 7872e223c9..bc12a92d5e 100644 --- a/packages/app/src/sandbox/dialog-mounts.tsx +++ b/packages/app/src/sandbox/dialog-mounts.tsx @@ -35,7 +35,7 @@ export function DialogSandboxMounts(props: { id: string }) { when={item()} fallback={ - + {language.t("sandbox.mounts.title")} @@ -137,7 +137,7 @@ function SandboxMountsEditor(props: { id: string }) { return ( - + {language.t("sandbox.mounts.title")} diff --git a/packages/app/src/wsl/dialog-add-server.tsx b/packages/app/src/wsl/dialog-add-server.tsx index 01555fdd7f..de69cd1dbf 100644 --- a/packages/app/src/wsl/dialog-add-server.tsx +++ b/packages/app/src/wsl/dialog-add-server.tsx @@ -80,7 +80,7 @@ export function DialogAddWslServer(props: DialogWslServerProps = {}) { } > - + {controller.view() === "main" ? language.t("wsl.server.add") : language.t("wsl.onboarding.installDistro")} diff --git a/packages/app/src/wsl/dialog-delete.tsx b/packages/app/src/wsl/dialog-delete.tsx index 3cd3662642..1cf6886c2e 100644 --- a/packages/app/src/wsl/dialog-delete.tsx +++ b/packages/app/src/wsl/dialog-delete.tsx @@ -17,7 +17,7 @@ export function DialogDeleteWslServer(props: { distro: string; onConfirm: () => return ( - + {language.t("wsl.delete.title")} diff --git a/packages/ui/src/components/dialog.css b/packages/ui/src/components/dialog.css index f17232c350..21add40638 100644 --- a/packages/ui/src/components/dialog.css +++ b/packages/ui/src/components/dialog.css @@ -21,6 +21,9 @@ z-index: 50; width: min(calc(100vw - 16px), 640px); height: min(calc(100vh - 16px), 512px); + /* Keeps a fit dialog whose content is shorter than the corner close button + from cutting the button off. */ + min-height: 52px; display: flex; flex-direction: column; align-items: center; @@ -28,6 +31,7 @@ overflow: visible; [data-slot="dialog-content"] { + position: relative; display: flex; flex-direction: column; align-items: flex-start; @@ -54,13 +58,18 @@ [data-slot="dialog-header"] { display: flex; - padding: 16px 20px; + /* Right padding clears the corner close button (20px inset + 24px + button + 8px gap) so a long title or an action never runs under it. */ + padding: 16px 52px 16px 20px; + gap: 8px; justify-content: space-between; align-items: center; flex-shrink: 0; align-self: stretch; [data-slot="dialog-title"] { + flex: 1; + min-width: 0; color: var(--text-strong); /* text-16-medium */ @@ -71,7 +80,23 @@ line-height: var(--line-height-x-large); /* 150% */ letter-spacing: var(--letter-spacing-tight); } - /* [data-slot="dialog-close-button"] {} */ + } + + /* Same coordinates the header used to lay this button out at, so headed + dialogs look unchanged while header-less ones gain the button. Above + the settings tab header, which is sticky at z-index 10. */ + > [data-slot="dialog-close-button"] { + position: absolute; + top: 16px; + right: 20px; + z-index: 30; + } + + /* Nothing above the body to reserve room, so the button floats over the + content and sits a little tighter into the corner. */ + &[data-no-header] > [data-slot="dialog-close-button"] { + top: 12px; + right: 12px; } [data-slot="dialog-description"] { diff --git a/packages/ui/src/components/dialog.stories.tsx b/packages/ui/src/components/dialog.stories.tsx index 60cd0a1c19..329398e043 100644 --- a/packages/ui/src/components/dialog.stories.tsx +++ b/packages/ui/src/components/dialog.stories.tsx @@ -13,12 +13,14 @@ Provide concise title/description and keep body focused. - Optional: \`title\`, \`description\`, \`action\`. - \`size\`: normal | large | x-large. - \`fit\` and \`transition\` control layout and animation. +- \`closeLabel\` and \`hideClose\` control the corner close button. ### Variants and states - Sizes and optional header/action controls. ### Behavior - Intended to be rendered via \`useDialog().show\`. +- Dismissal is the corner close button or Escape; a click outside never closes it. ### Accessibility - TODO: confirm focus trapping and aria attributes from Kobalte Dialog. @@ -62,6 +64,29 @@ export const Basic = { }, } +export const NoHeader = { + render: () => { + const dialog = useDialog() + const open = () => + dialog.show(() => ( + +
+ A dialog with no header still gets the corner close button. Header-less dialogs lay their own content out, + so they leave the corner clear the way this one does. +
+
+ )) + + onMount(open) + + return ( + + ) + }, +} + export const Sizes = { render: () => { const dialog = useDialog() diff --git a/packages/ui/src/components/dialog.tsx b/packages/ui/src/components/dialog.tsx index 981e3f45d7..cdd458fd26 100644 --- a/packages/ui/src/components/dialog.tsx +++ b/packages/ui/src/components/dialog.tsx @@ -1,5 +1,5 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" -import { ComponentProps, JSXElement, Match, ParentProps, Show, Switch } from "solid-js" +import { ComponentProps, JSXElement, ParentProps, Show } from "solid-js" import { useI18n } from "../context/i18n" import { IconButton } from "./icon-button" @@ -12,6 +12,11 @@ export interface DialogProps extends ParentProps { classList?: ComponentProps<"div">["classList"] fit?: boolean transition?: boolean + closeLabel?: string + // Only for dialogs the user must not dismiss — the configure gate, which the + // dialog stack also marks persistent. A backdrop click no longer closes + // anything, so hiding the button leaves the dialog with no exit at all. + hideClose?: boolean } export function Dialog(props: DialogProps) { @@ -31,6 +36,10 @@ export function Dialog(props: DialogProps) { ...props.classList, [props.class ?? ""]: !!props.class, }} + // The dialog closes on its close button or Escape, never on a click + // outside it — an accidental click in the backdrop used to discard + // whatever the dialog was holding. + onInteractOutside={(e) => e.preventDefault()} onOpenAutoFocus={(e) => { const target = e.currentTarget as HTMLElement | null const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null @@ -45,18 +54,7 @@ export function Dialog(props: DialogProps) { {props.title} - - {props.action} - - - - + {props.action}
@@ -65,6 +63,18 @@ export function Dialog(props: DialogProps) {
{props.children}
+ {/* Pinned to the content's top-right corner rather than laid out in + the header, so header-less dialogs get one too. The header + reserves the room it covers (see dialog.css). */} + + + diff --git a/packages/ui/src/components/image-preview.tsx b/packages/ui/src/components/image-preview.tsx index 8462ce2560..809f6f838d 100644 --- a/packages/ui/src/components/image-preview.tsx +++ b/packages/ui/src/components/image-preview.tsx @@ -12,7 +12,12 @@ export function ImagePreview(props: ImagePreviewProps) { return (
- + e.preventDefault()} + >
void setClosing: (closing: boolean) => void - // While set, user-driven closes (Escape, backdrop click, Kobalte outside - // interaction) are no-ops for this dialog; programmatic close() still works - // so flows can advance. Dialogs pushed on top stay dismissable. + // While set, user-driven closes (Escape, the corner close button) are no-ops + // for this dialog; programmatic close() still works so flows can advance. + // Dialogs pushed on top stay dismissable. persistent: boolean } @@ -112,14 +112,11 @@ function init() { }} > - { - if (isPersistent(id)) return - close(id) - }} - /> + {/* No onClick: a click on the backdrop must not dismiss the + dialog. Every dialog carries a corner close button instead + (see the Dialog components in components/ and v2/), which is + what onOpenChange below answers. */} +
[data-slot="dialog-close-button"] { + position: absolute; + top: 14px; + right: 14px; + z-index: 30; + display: flex; + justify-content: center; + align-items: center; + width: 20px; + height: 20px; + border-radius: 4px; + flex-shrink: 0; + cursor: pointer; + color: var(--v2-icon-icon-muted); - &[data-hide-close] [data-slot="dialog-close-button"] { - display: none; + &:hover { + background: var(--v2-overlay-simple-overlay-hover); } } diff --git a/packages/ui/src/v2/components/dialog-v2.stories.tsx b/packages/ui/src/v2/components/dialog-v2.stories.tsx index 198da5c663..9300c5c581 100644 --- a/packages/ui/src/v2/components/dialog-v2.stories.tsx +++ b/packages/ui/src/v2/components/dialog-v2.stories.tsx @@ -8,13 +8,14 @@ Dialog content wrapper built on Kobalte's dialog primitive with v2 styling. Compose with \`DialogHeader\`, \`DialogTitle\`, \`DialogTitleGroup\`, \`DialogBody\`, and \`DialogFooter\`. ### API -- \`Dialog\`: \`size\` (normal | large | x-large), \`variant\`, \`fit\`. -- \`DialogHeader\`: row container with optional \`closeLabel\` and \`hideClose\`. +- \`Dialog\`: \`size\` (normal | large | x-large), \`variant\`, \`fit\`, \`closeLabel\`, \`hideClose\`. +- \`DialogHeader\`: row container; the close button is drawn by \`Dialog\` in the content's corner. - \`DialogTitle\`: accessible single-line header title. - \`DialogTitleGroup\`: column with \`title\` and required \`description\`. ### Accessibility - Focus trapping and aria attributes provided by Kobalte Dialog. +- Dismissal is the corner close button or Escape; a click outside never closes it. ### Theming/tokens - Uses \`data-component="dialog-v2"\` and slot attributes. @@ -123,6 +124,27 @@ export const TitleOnly = { ), } +export const NoHeader = { + render: () => ( + + + Open dialog + + + + + +
+ A dialog with no header still gets the corner close button. Header-less dialogs lay their own content out, + so they leave the corner clear the way this one does. +
+
+
+
+
+ ), +} + export const HeaderControls = { render: () => ( diff --git a/packages/ui/src/v2/components/dialog-v2.tsx b/packages/ui/src/v2/components/dialog-v2.tsx index b9dd2521e2..e85cbb256c 100644 --- a/packages/ui/src/v2/components/dialog-v2.tsx +++ b/packages/ui/src/v2/components/dialog-v2.tsx @@ -1,5 +1,6 @@ import { Dialog as Kobalte } from "@kobalte/core/dialog" import { type ComponentProps, type JSXElement, type ParentProps, Show, children, splitProps } from "solid-js" +import { useI18n } from "../../context/i18n" import "./dialog-v2.css" export interface DialogProps extends ParentProps { @@ -9,13 +10,15 @@ export interface DialogProps extends ParentProps { containerClass?: ComponentProps<"div">["class"] classList?: ComponentProps<"div">["classList"] fit?: boolean -} - -export interface DialogHeaderProps extends ParentProps { closeLabel?: string + // Only for dialogs the user must not dismiss — the configure gate, which the + // dialog stack also marks persistent. A backdrop click no longer closes + // anything, so hiding the button leaves the dialog with no exit at all. hideClose?: boolean } +export interface DialogHeaderProps extends ParentProps {} + export interface DialogTitleGroupProps { title?: JSXElement description: JSXElement @@ -51,36 +54,25 @@ export function DialogTitleGroup(props: DialogTitleGroupProps) { } export function DialogHeader(props: DialogHeaderProps) { - const [local] = splitProps(props, ["closeLabel", "hideClose", "children"]) - const hideClose = () => local.hideClose === true - - return ( -
- {local.children} - {!hideClose() && ( - - - - )} -
- ) + // The close button lives on Dialog, pinned to the content's corner, so that + // header-less dialogs get one too; the header only reserves the room it + // covers (see dialog-v2.css). + return
{props.children}
} export function Dialog(props: DialogProps) { - const [local] = splitProps(props, ["size", "variant", "class", "containerClass", "classList", "fit", "children"]) + const i18n = useI18n() + const [local] = splitProps(props, [ + "size", + "variant", + "class", + "containerClass", + "classList", + "fit", + "closeLabel", + "hideClose", + "children", + ]) return (
e.preventDefault()} onOpenAutoFocus={(e) => { const target = e.currentTarget as HTMLElement | null const autofocusEl = target?.querySelector("[autofocus]") as HTMLElement | null @@ -106,6 +102,27 @@ export function Dialog(props: DialogProps) { }} > {local.children} + + + + +