diff --git a/docs/widgets/modal.md b/docs/widgets/modal.md index 1f068bd0..12918949 100644 --- a/docs/widgets/modal.md +++ b/docs/widgets/modal.md @@ -29,7 +29,7 @@ ui.layers([ | `title` | `string` | - | Optional modal header title | | `content` | `VNode` | **required** | Main modal body | | `actions` | `VNode[]` | `[]` | Action row (typically buttons) | -| `width` | `number \| "auto"` | `"auto"` | Preferred modal width | +| `width` | `number \| "auto"` | `~70%` | Preferred modal width | | `maxWidth` | `number` | - | Maximum width constraint | | `backdrop` | `"none" \| "dim" \| "opaque"` | `"dim"` | Backdrop style | | `closeOnBackdrop` | `boolean` | `true` | Close when clicking backdrop | @@ -68,6 +68,7 @@ ui.modal({ - Modals are rendered by conditionally including them in the tree (there is no `open` prop). - Render modals inside `ui.layers(...)` so they stack above base content. - Backdrops are rendered behind the modal. `"dim"` uses a light shade pattern; `"opaque"` clears the area behind the modal to the theme background color. +- `width: "auto"` sizes to content/actions and is clamped by `maxWidth` and the viewport. ## Related diff --git a/packages/core/src/layout/__tests__/layout.edgecases.test.ts b/packages/core/src/layout/__tests__/layout.edgecases.test.ts index f6febc5a..a8571ec7 100644 --- a/packages/core/src/layout/__tests__/layout.edgecases.test.ts +++ b/packages/core/src/layout/__tests__/layout.edgecases.test.ts @@ -176,4 +176,41 @@ describe("layout edge cases", () => { if (!laidOut.ok) return; assert.deepEqual(laidOut.value.rect, { x: 0, y: 0, w: 20, h: 6 }); }); + + test("modal width:'auto' measures content and actions", () => { + const modal: VNode = { + kind: "modal", + props: { + id: "m-auto", + width: "auto", + content: { kind: "text", text: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", props: {} }, // 30 cols + actions: Object.freeze([{ kind: "button", props: { id: "ok", label: "OK" } }]), + }, + }; + const tree: VNode = { kind: "layers", props: {}, children: Object.freeze([modal]) }; + const laidOut = mustLayout(tree, 80, 25); + const modalLayout = laidOut.children[0]; + assert.ok(modalLayout !== undefined, "expected modal layout node"); + if (!modalLayout) return; + assert.equal(modalLayout.rect.w, 32); + }); + + test("modal width:'auto' respects maxWidth", () => { + const modal: VNode = { + kind: "modal", + props: { + id: "m-auto-maxw", + width: "auto", + maxWidth: 20, + content: { kind: "text", text: "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", props: {} }, // 30 cols + actions: Object.freeze([{ kind: "button", props: { id: "ok", label: "OK" } }]), + }, + }; + const tree: VNode = { kind: "layers", props: {}, children: Object.freeze([modal]) }; + const laidOut = mustLayout(tree, 80, 25); + const modalLayout = laidOut.children[0]; + assert.ok(modalLayout !== undefined, "expected modal layout node"); + if (!modalLayout) return; + assert.equal(modalLayout.rect.w, 20); + }); }); diff --git a/packages/core/src/layout/kinds/overlays.ts b/packages/core/src/layout/kinds/overlays.ts index 4c116516..8733617b 100644 --- a/packages/core/src/layout/kinds/overlays.ts +++ b/packages/core/src/layout/kinds/overlays.ts @@ -3,6 +3,7 @@ import { clampNonNegative, clampWithin } from "../engine/bounds.js"; import { isVNode } from "../engine/guards.js"; import { ok } from "../engine/result.js"; import type { LayoutTree } from "../engine/types.js"; +import { measureTextCells } from "../textMeasure.js"; import type { Axis, Rect, Size } from "../types.js"; import type { LayoutResult } from "../validateProps.js"; @@ -203,37 +204,70 @@ export function layoutOverlays( const title = typeof props.title === "string" ? props.title : undefined; + const content = isVNode(props.content) ? props.content : null; + const actionsRaw = Array.isArray(props.actions) ? props.actions : []; + const actions: VNode[] = []; + for (const a of actionsRaw) { + if (isVNode(a)) actions.push(a); + } + + const border = 1; + const titleH = title ? 1 : 0; + const actionsH = actions.length > 0 ? 1 : 0; + const maxWidth = typeof props.maxWidth === "number" && props.maxWidth > 0 ? Math.floor(props.maxWidth) : rectW; - let modalW = - typeof props.width === "number" && props.width > 0 - ? Math.floor(props.width) - : Math.floor(rectW * 0.7); - - modalW = clampNonNegative(Math.min(modalW, maxWidth)); - modalW = clampWithin(modalW, Math.min(10, rectW), rectW); - if (rectW >= 4) modalW = Math.min(modalW, rectW - 2); - let modalH = Math.floor(rectH * 0.6); modalH = clampWithin(modalH, Math.min(5, rectH), rectH); if (rectH >= 4) modalH = Math.min(modalH, rectH - 2); - const mx = x + Math.floor((rectW - modalW) / 2); - const my = y + Math.floor((rectH - modalH) / 2); + const maxModalW = clampNonNegative(Math.min(rectW, maxWidth)); + const maxInnerW = clampNonNegative(maxModalW - border * 2); + const maxInnerH = clampNonNegative(modalH - border * 2 - titleH - actionsH); - const content = isVNode(props.content) ? props.content : null; - const actionsRaw = Array.isArray(props.actions) ? props.actions : []; - const actions: VNode[] = []; - for (const a of actionsRaw) { - if (isVNode(a)) actions.push(a); + let modalW: number; + if (typeof props.width === "number" && props.width > 0) { + modalW = Math.floor(props.width); + } else if (props.width === "auto") { + let contentW = 0; + if (content) { + const sizeRes = measureNode(content, maxInnerW, maxInnerH, "column"); + if (!sizeRes.ok) return sizeRes; + contentW = clampNonNegative(Math.min(maxInnerW, sizeRes.value.w)); + } + + let actionsW = 0; + if (actions.length > 0) { + const gap = 1; + let total = 0; + for (let i = 0; i < actions.length; i++) { + const a = actions[i]; + if (!a) continue; + const sizeRes = measureNode(a, maxInnerW, 1, "row"); + if (!sizeRes.ok) return sizeRes; + const aw = clampNonNegative(Math.min(maxInnerW, sizeRes.value.w)); + total += aw; + if (i < actions.length - 1) total += gap; + } + actionsW = total; + } + + const innerW = Math.max(contentW, actionsW); + const titleW = title ? measureTextCells(title) + 4 : 0; + modalW = Math.max(innerW + border * 2, titleW); + } else { + modalW = Math.floor(rectW * 0.7); } - const border = 1; - const titleH = title ? 1 : 0; - const actionsH = actions.length > 0 ? 1 : 0; + modalW = clampNonNegative(Math.min(modalW, maxWidth)); + modalW = clampWithin(modalW, Math.min(10, rectW), rectW); + if (rectW >= 4) modalW = Math.min(modalW, rectW - 2); + + const mx = x + Math.floor((rectW - modalW) / 2); + const my = y + Math.floor((rectH - modalH) / 2); const innerX = mx + border; const innerY = my + border + titleH;