mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(ui): preserve responsive preview and modal owner layouts
This commit is contained in:
committed by
Shakker
parent
b067311551
commit
c8b5df08eb
@@ -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();
|
||||
|
||||
@@ -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>) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user