From c8b5df08eb0b178e021002144bd003aa3d69f890 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 31 Jul 2026 07:21:41 -0700 Subject: [PATCH] fix(ui): preserve responsive preview and modal owner layouts --- .../file-preview-modal.browser.test.ts | 194 ++++++++++++++++++ ui/src/components/file-preview-modal.test.ts | 11 + ui/src/components/file-preview-modal.ts | 28 +++ ui/src/components/modal-dialog.test.ts | 17 ++ ui/src/components/modal-dialog.ts | 9 +- 5 files changed, 258 insertions(+), 1 deletion(-) create mode 100644 ui/src/components/file-preview-modal.browser.test.ts diff --git a/ui/src/components/file-preview-modal.browser.test.ts b/ui/src/components/file-preview-modal.browser.test.ts new file mode 100644 index 000000000000..304d94b03cfc --- /dev/null +++ b/ui/src/components/file-preview-modal.browser.test.ts @@ -0,0 +1,194 @@ +import type WaDialog from "@awesome.me/webawesome/dist/components/dialog/dialog.js"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { OpenClawFilePreviewModal } from "./file-preview-modal.ts"; +import type { OpenClawModalDialog } from "./modal-dialog.ts"; +import "./file-preview-modal-registration.ts"; + +const browserMode = "__vitest_browser__" in globalThis; +const initialFilePath = "templates/digest.md"; + +const files = [ + { + path: initialFilePath, + size: "2.1 KB", + contents: "Review the complete support-file contents.", + }, + { + path: "filters/auto-senders.txt", + size: "418 B", + contents: "noreply@example.com", + }, +]; + +afterEach(() => { + document.body.replaceChildren(); +}); + +async function resolveRenderedDialog(modal: OpenClawModalDialog) { + await modal.updateComplete; + const webAwesomeDialog = modal.shadowRoot?.querySelector("wa-dialog"); + expect(webAwesomeDialog).toBeInstanceOf(HTMLElement); + await webAwesomeDialog?.updateComplete; + await new Promise((resolve) => { + requestAnimationFrame(() => resolve()); + }); + const dialog = webAwesomeDialog?.shadowRoot?.querySelector("dialog"); + expect(dialog?.open).toBe(true); + // Web Awesome opens at 80% scale; measure the settled dialog, not its first animation frame. + await Promise.all(dialog!.getAnimations().map((animation) => animation.finished)); + return dialog!; +} + +async function mountPreview(width: number) { + const { page } = await import("vitest/browser"); + await page.viewport(width, 844); + + const preview = document.createElement("openclaw-file-preview-modal") as OpenClawFilePreviewModal; + preview.style.setProperty("--wa-transition-normal", "150ms"); + preview.files = files; + preview.activePath = initialFilePath; + document.body.append(preview); + await preview.updateComplete; + + const ownerDialog = + preview.shadowRoot?.querySelector("openclaw-modal-dialog"); + expect(ownerDialog).toBeInstanceOf(HTMLElement); + const dialog = await resolveRenderedDialog(ownerDialog!); + return { preview, dialog }; +} + +async function mountModal( + width: number, + options: { + fullscreen?: boolean; + kind?: "drawer" | "nav-drawer"; + modalWidth: string; + }, +) { + const { page } = await import("vitest/browser"); + await page.viewport(width, 844); + + const modal = document.createElement("openclaw-modal-dialog"); + modal.label = "Preview"; + modal.style.setProperty("--wa-transition-normal", "150ms"); + modal.style.setProperty("--openclaw-modal-width", options.modalWidth); + modal.classList.toggle("fullscreen", options.fullscreen === true); + modal.classList.toggle("drawer", options.kind !== undefined); + modal.classList.toggle("nav-drawer", options.kind === "nav-drawer"); + const content = document.createElement("div"); + content.style.cssText = "width: 100%; height: 80px;"; + modal.append(content); + document.body.append(modal); + return await resolveRenderedDialog(modal); +} + +describe.runIf(browserMode)("file preview modal responsive layout", () => { + it.each([390, 320])("keeps source and copy visible at a %dpx viewport", async (width) => { + const { preview, dialog } = await mountPreview(width); + const list = preview.shadowRoot?.querySelector(".list"); + const detail = preview.shadowRoot?.querySelector(".detail"); + const copy = preview.shadowRoot?.querySelector(".chat-copy-btn"); + const search = preview.shadowRoot?.querySelector(".search"); + const source = preview.shadowRoot?.querySelector(".code-chunk"); + expect(list).toBeInstanceOf(HTMLElement); + expect(detail).toBeInstanceOf(HTMLElement); + expect(copy).toBeInstanceOf(HTMLButtonElement); + expect(search).toBeInstanceOf(HTMLInputElement); + expect(source).toBeInstanceOf(HTMLElement); + + const dialogBounds = dialog.getBoundingClientRect(); + const listBounds = list!.getBoundingClientRect(); + const detailBounds = detail!.getBoundingClientRect(); + const copyBounds = copy!.getBoundingClientRect(); + const searchBounds = search!.getBoundingClientRect(); + const sourceBounds = source!.getBoundingClientRect(); + const sourceTextRange = document.createRange(); + sourceTextRange.selectNodeContents(source!); + const sourceTextBounds = sourceTextRange.getBoundingClientRect(); + + expect(dialogBounds.left).toBeGreaterThanOrEqual(0); + expect(dialogBounds.right).toBeLessThanOrEqual(window.innerWidth + 1); + expect(dialogBounds.top).toBeGreaterThanOrEqual(0); + expect(dialogBounds.bottom).toBeLessThanOrEqual(window.innerHeight + 1); + expect(detailBounds.top).toBeGreaterThanOrEqual(listBounds.bottom - 1); + expect(detailBounds.width).toBeGreaterThan(200); + expect(copyBounds.top).toBeGreaterThanOrEqual(0); + expect(copyBounds.bottom).toBeLessThanOrEqual(window.innerHeight + 1); + expect(copyBounds.left).toBeGreaterThanOrEqual(dialogBounds.left - 1); + expect(copyBounds.right).toBeLessThanOrEqual(dialogBounds.right + 1); + expect(searchBounds.width).toBeGreaterThan(80); + expect(searchBounds.left).toBeGreaterThanOrEqual(0); + expect(searchBounds.right).toBeLessThanOrEqual(dialogBounds.right + 1); + expect(searchBounds.top).toBeGreaterThanOrEqual(0); + expect(searchBounds.bottom).toBeLessThanOrEqual(window.innerHeight + 1); + expect(sourceBounds.width).toBeGreaterThan(100); + expect(sourceBounds.height).toBeGreaterThan(0); + expect(sourceBounds.top).toBeGreaterThanOrEqual(0); + expect(sourceBounds.top).toBeLessThan(window.innerHeight); + expect(sourceTextBounds.width).toBeGreaterThan(0); + expect(sourceTextBounds.height).toBeGreaterThan(0); + expect(sourceTextBounds.left).toBeGreaterThanOrEqual(dialogBounds.left - 1); + expect(sourceTextBounds.right).toBeLessThanOrEqual(dialogBounds.right + 1); + expect(sourceTextBounds.top).toBeGreaterThanOrEqual(0); + expect(sourceTextBounds.bottom).toBeLessThanOrEqual(window.innerHeight + 1); + expect(source?.textContent).toContain("Review the complete support-file contents."); + + const selected = vi.fn(); + preview.addEventListener("file-preview-select", selected); + search?.focus(); + const { userEvent } = await import("vitest/browser"); + await userEvent.keyboard("{ArrowDown}"); + expect(selected).toHaveBeenCalledOnce(); + expect(selected.mock.calls[0]?.[0].detail).toBe("filters/auto-senders.txt"); + }); + + it("preserves the desktop side-by-side file layout", async () => { + const { preview, dialog } = await mountPreview(1280); + const list = preview.shadowRoot?.querySelector(".list"); + const detail = preview.shadowRoot?.querySelector(".detail"); + expect(list).toBeInstanceOf(HTMLElement); + expect(detail).toBeInstanceOf(HTMLElement); + + const listBounds = list!.getBoundingClientRect(); + const detailBounds = detail!.getBoundingClientRect(); + expect(listBounds.width).toBeCloseTo(360, 0); + expect(detailBounds.left).toBeGreaterThanOrEqual(listBounds.right - 1); + expect(detailBounds.right).toBeLessThanOrEqual(dialog.getBoundingClientRect().right + 1); + }); + + it.each([1280, 390])("expands fullscreen previews at a %dpx viewport", async (width) => { + const dialog = await mountModal(width, { + fullscreen: true, + modalWidth: "min(1040px, calc(100vw - 32px))", + }); + + expect(dialog.getBoundingClientRect().width).toBeGreaterThanOrEqual(width - 21); + }); + + it("preserves narrower owner-defined modal widths on phones", async () => { + const dialog = await mountModal(390, { modalWidth: "200px" }); + + expect(dialog.getBoundingClientRect().width).toBeCloseTo(200, 0); + }); + + it.each([ + { viewport: 390, expectedWidth: 390 }, + { viewport: 1280, expectedWidth: 460 }, + ])("keeps workboard drawers edge-to-edge on phones", async ({ viewport, expectedWidth }) => { + const dialog = await mountModal(viewport, { + kind: "drawer", + modalWidth: "min(460px, 100vw)", + }); + + expect(dialog.getBoundingClientRect().width).toBeCloseTo(expectedWidth, 0); + }); + + it("preserves the navigation drawer's narrower owned width", async () => { + const dialog = await mountModal(390, { + kind: "nav-drawer", + modalWidth: "min(460px, 100vw)", + }); + + expect(dialog.getBoundingClientRect().width).toBeCloseTo(320, 0); + }); +}); diff --git a/ui/src/components/file-preview-modal.test.ts b/ui/src/components/file-preview-modal.test.ts index 7033fa7afe49..9da5333d2efb 100644 --- a/ui/src/components/file-preview-modal.test.ts +++ b/ui/src/components/file-preview-modal.test.ts @@ -90,6 +90,17 @@ describe("openclaw-file-preview-modal", () => { expect(closeButton?.querySelector(".kbd")?.textContent).toBe("esc"); }); + it("stacks the file list above its preview on narrow screens", () => { + const styles = OpenClawFilePreviewModal.styles.cssText; + + expect(styles).toMatch( + /@media\s*\(max-width:\s*640px\)\s*\{[\s\S]*?\.body\s*\{[^}]*grid-template-columns:\s*minmax\(0,\s*1fr\);[^}]*grid-template-rows:/u, + ); + expect(styles).toMatch( + /@media\s*\(max-width:\s*640px\)\s*\{[\s\S]*?\.list\s*\{[^}]*border-right:\s*0;[^}]*border-bottom:/u, + ); + }); + it("emits controlled query, select, and close events", async () => { const modal = await renderPreview(); const onQuery = vi.fn(); diff --git a/ui/src/components/file-preview-modal.ts b/ui/src/components/file-preview-modal.ts index 28181405e215..e279bab8aaf3 100644 --- a/ui/src/components/file-preview-modal.ts +++ b/ui/src/components/file-preview-modal.ts @@ -70,6 +70,7 @@ export class OpenClawFilePreviewModal extends OpenClawLitElement { .search { flex: 1; + min-width: 0; background: transparent; border: none; outline: none; @@ -436,6 +437,33 @@ export class OpenClawFilePreviewModal extends OpenClawLitElement { color: var(--muted); max-width: 380px; } + + @media (max-width: 640px) { + .head { + padding: 12px; + } + + .body { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, min(180px, 30dvh)) minmax(0, 1fr); + } + + .list { + min-width: 0; + border-right: 0; + border-bottom: 1px solid var(--border); + padding: 10px 8px; + } + + .item { + min-width: 0; + } + + .foot { + gap: 8px; + padding: 10px 12px; + } + } `; protected override willUpdate(changed: PropertyValues) { diff --git a/ui/src/components/modal-dialog.test.ts b/ui/src/components/modal-dialog.test.ts index ddb992b7c1d4..838066707609 100644 --- a/ui/src/components/modal-dialog.test.ts +++ b/ui/src/components/modal-dialog.test.ts @@ -114,6 +114,23 @@ describe("openclaw-modal-dialog", () => { ); }); + it("keeps responsive width and maximum-width limits owned by the same variant", () => { + const styles = OpenClawModalDialog.styles.cssText; + + expect(styles).toMatch( + /:host\(\.fullscreen\)\s+wa-dialog::part\(dialog\)\s*\{[^}]*max-width:\s*calc\(100vw\s*-\s*20px\);/u, + ); + expect(styles).toMatch( + /@media\s*\(max-width:\s*640px\)\s*\{[\s\S]*?wa-dialog\s*\{[^}]*--width:\s*min\(var\(--openclaw-modal-width,\s*540px\),\s*calc\(100vw\s*-\s*24px\)\);[\s\S]*?wa-dialog::part\(dialog\)\s*\{[^}]*max-width:\s*var\(--openclaw-modal-max-width,\s*calc\(100vw\s*-\s*24px\)\);/u, + ); + expect(styles).toMatch( + /:host\(\.drawer\)\s+wa-dialog\s*\{[^}]*--width:\s*min\(var\(--openclaw-modal-width,\s*100vw\),\s*100vw\);/u, + ); + expect(styles).toMatch( + /:host\(\.drawer\)\s+wa-dialog::part\(dialog\)\s*\{[^}]*max-width:\s*100vw;/u, + ); + }); + it("emits modal-cancel on Escape", async () => { const { modal, dialog } = await renderModal(); const onCancel = vi.fn(); diff --git a/ui/src/components/modal-dialog.ts b/ui/src/components/modal-dialog.ts index 4916b47c43c4..475602a7eb8a 100644 --- a/ui/src/components/modal-dialog.ts +++ b/ui/src/components/modal-dialog.ts @@ -49,6 +49,7 @@ export class OpenClawModalDialog extends OpenClawLitElement { } :host(.fullscreen) wa-dialog::part(dialog) { + max-width: calc(100vw - 20px); max-height: calc(100dvh - 20px); } @@ -57,8 +58,13 @@ export class OpenClawModalDialog extends OpenClawLitElement { margin-block-end: auto; } + :host(.drawer) wa-dialog { + --width: min(var(--openclaw-modal-width, 100vw), 100vw); + } + :host(.drawer) wa-dialog::part(dialog) { height: 100dvh; + max-width: 100vw; max-height: 100dvh; margin: 0 0 0 auto; border-radius: 0; @@ -90,10 +96,11 @@ export class OpenClawModalDialog extends OpenClawLitElement { @media (max-width: 640px) { wa-dialog { - --width: calc(100vw - 24px); + --width: min(var(--openclaw-modal-width, 540px), calc(100vw - 24px)); } wa-dialog::part(dialog) { + max-width: var(--openclaw-modal-max-width, calc(100vw - 24px)); max-height: 90dvh; } }