fix(ui): preserve responsive preview and modal owner layouts

This commit is contained in:
Peter Steinberger
2026-07-31 07:21:41 -07:00
committed by Shakker
parent b067311551
commit c8b5df08eb
5 changed files with 258 additions and 1 deletions
@@ -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<WaDialog>("wa-dialog");
expect(webAwesomeDialog).toBeInstanceOf(HTMLElement);
await webAwesomeDialog?.updateComplete;
await new Promise<void>((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<OpenClawModalDialog>("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<HTMLElement>(".list");
const detail = preview.shadowRoot?.querySelector<HTMLElement>(".detail");
const copy = preview.shadowRoot?.querySelector<HTMLButtonElement>(".chat-copy-btn");
const search = preview.shadowRoot?.querySelector<HTMLInputElement>(".search");
const source = preview.shadowRoot?.querySelector<HTMLElement>(".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<HTMLElement>(".list");
const detail = preview.shadowRoot?.querySelector<HTMLElement>(".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);
});
});
@@ -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();
+28
View File
@@ -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<this>) {
+17
View File
@@ -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();
+8 -1
View File
@@ -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;
}
}