mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
fix(ui): make control prompts real modals
Introduce a native dialog-backed Control UI modal primitive and migrate the exec approval, gateway URL confirmation, and dreaming restart confirmation prompts to it. The modal primitive provides aria-modal semantics, shadow-root-local labels/descriptions, focus trapping, safe initial focus, Escape cancellation, and focus restoration while preserving the existing prompt content and decision semantics. Validation: - pnpm lint --threads=8 - pnpm --dir ui test src/ui/components/modal-dialog.test.ts src/ui/views/exec-approval.test.ts src/ui/navigation.browser.test.ts - pnpm test:ui - pnpm exec oxfmt --check --threads=1 ui/src/ui/components/modal-dialog.ts ui/src/styles/config-quick.test.ts - git diff --check CI note: checks-node-core-support-boundary is failing in test/scripts/docker-build-helper.test.ts on an unrelated package-acceptance assertion; the failing files are identical to origin/main and outside this UI-only PR.
This commit is contained in:
@@ -3285,21 +3285,9 @@ td.data-table-key-col {
|
||||
Exec Approval Modal
|
||||
=========================================== */
|
||||
|
||||
.exec-approval-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
.exec-approval-card {
|
||||
width: min(540px, 100%);
|
||||
max-height: calc(100dvh - 48px);
|
||||
width: 100%;
|
||||
max-height: inherit;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--card);
|
||||
@@ -5415,14 +5403,8 @@ details[open] > .ov-expandable-toggle::after {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.exec-approval-overlay {
|
||||
padding: 12px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.exec-approval-card {
|
||||
padding: 16px;
|
||||
max-height: 90dvh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function readComponentsCss(): string {
|
||||
const cssPath = [
|
||||
resolve(process.cwd(), "ui/src/styles/components.css"),
|
||||
resolve(process.cwd(), "..", "ui/src/styles/components.css"),
|
||||
].find((candidate) => existsSync(candidate));
|
||||
expect(cssPath).toBeTruthy();
|
||||
return readFileSync(cssPath!, "utf8");
|
||||
}
|
||||
|
||||
describe("agent fallback chip styles", () => {
|
||||
it("styles the chip remove control inside the agent model input", () => {
|
||||
const css = readFileSync(path.join(process.cwd(), "ui/src/styles/components.css"), "utf8");
|
||||
const css = readComponentsCss();
|
||||
|
||||
expect(css).toContain(".agent-chip-input .chip {");
|
||||
expect(css).toContain(".agent-chip-input .chip-remove {");
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const css = readFileSync(path.join(process.cwd(), "ui/src/styles/config-quick.css"), "utf8");
|
||||
const cssPath = [
|
||||
resolve(process.cwd(), "ui/src/styles/config-quick.css"),
|
||||
resolve(process.cwd(), "..", "ui/src/styles/config-quick.css"),
|
||||
].find((candidate) => existsSync(candidate));
|
||||
if (!cssPath) {
|
||||
throw new Error(`config-quick.css not found from cwd: ${process.cwd()}`);
|
||||
}
|
||||
const css = readFileSync(cssPath, "utf8");
|
||||
|
||||
describe("config-quick styles", () => {
|
||||
it("includes the local user identity quick-settings styles", () => {
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function readMobileCss(): string {
|
||||
const cssPath = [
|
||||
resolve(process.cwd(), "ui/src/styles/layout.mobile.css"),
|
||||
resolve(process.cwd(), "..", "ui/src/styles/layout.mobile.css"),
|
||||
].find((candidate) => existsSync(candidate));
|
||||
expect(cssPath).toBeTruthy();
|
||||
return readFileSync(cssPath!, "utf8");
|
||||
}
|
||||
|
||||
describe("chat header responsive mobile styles", () => {
|
||||
it("keeps the chat header and session controls from clipping on narrow widths", () => {
|
||||
const css = readFileSync(path.join(process.cwd(), "ui/src/styles/layout.mobile.css"), "utf8");
|
||||
const css = readMobileCss();
|
||||
|
||||
expect(css).toContain("@media (max-width: 1320px)");
|
||||
expect(css).toContain(".content--chat .content-header");
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
function stylePath(path: string): string {
|
||||
const cssPath = [resolve(process.cwd(), path), resolve(process.cwd(), "..", path)].find(
|
||||
(candidate) => existsSync(candidate),
|
||||
);
|
||||
expect(cssPath).toBeTruthy();
|
||||
return cssPath!;
|
||||
}
|
||||
|
||||
describe("markdown preview styles", () => {
|
||||
it("keeps the preview dialog canvas unified", async () => {
|
||||
const css = await readFile("ui/src/styles/components.css", "utf8");
|
||||
const css = await readFile(stylePath("ui/src/styles/components.css"), "utf8");
|
||||
|
||||
expect(css).toContain(".md-preview-dialog__header-main");
|
||||
expect(css).toContain(".md-preview-dialog__meta");
|
||||
@@ -15,7 +25,7 @@ describe("markdown preview styles", () => {
|
||||
});
|
||||
|
||||
it("keeps expanded previews focused on header controls and reading space", async () => {
|
||||
const css = await readFile("ui/src/styles/components.css", "utf8");
|
||||
const css = await readFile(stylePath("ui/src/styles/components.css"), "utf8");
|
||||
|
||||
expect(css).toContain(".md-preview-dialog__panel.fullscreen .md-preview-dialog__header-main");
|
||||
expect(css).toContain("clip-path: inset(50%);");
|
||||
@@ -27,7 +37,7 @@ describe("markdown preview styles", () => {
|
||||
});
|
||||
|
||||
it("styles preview header controls as compact icon buttons", async () => {
|
||||
const css = await readFile("ui/src/styles/components.css", "utf8");
|
||||
const css = await readFile(stylePath("ui/src/styles/components.css"), "utf8");
|
||||
|
||||
expect(css).toContain(".md-preview-icon-btn");
|
||||
expect(css).toContain("width: 36px;");
|
||||
@@ -36,7 +46,7 @@ describe("markdown preview styles", () => {
|
||||
});
|
||||
|
||||
it("keeps the sidebar reader shell in sidebar.css", async () => {
|
||||
const css = await readFile("ui/src/styles/chat/sidebar.css", "utf8");
|
||||
const css = await readFile(stylePath("ui/src/styles/chat/sidebar.css"), "utf8");
|
||||
|
||||
expect(css).toContain(".sidebar-markdown-shell__toolbar");
|
||||
expect(css).toContain(".sidebar-markdown-reader");
|
||||
|
||||
175
ui/src/ui/components/modal-dialog.test.ts
Normal file
175
ui/src/ui/components/modal-dialog.test.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { html, nothing, render } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { type OpenClawModalDialog } from "./modal-dialog.ts";
|
||||
import "./modal-dialog.ts";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
const showModalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLDialogElement.prototype,
|
||||
"showModal",
|
||||
);
|
||||
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
|
||||
|
||||
function nextFrame() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function installDialogPolyfill() {
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||
configurable: true,
|
||||
value(this: HTMLDialogElement) {
|
||||
this.setAttribute("open", "");
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||
configurable: true,
|
||||
value(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function restoreDescriptor(name: "showModal" | "close", descriptor?: PropertyDescriptor) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(HTMLDialogElement.prototype, name, descriptor);
|
||||
return;
|
||||
}
|
||||
delete (HTMLDialogElement.prototype as Partial<HTMLDialogElement>)[name];
|
||||
}
|
||||
|
||||
async function renderModal() {
|
||||
render(
|
||||
html`
|
||||
<openclaw-modal-dialog
|
||||
label="Confirm action"
|
||||
description="Review the operation before continuing."
|
||||
>
|
||||
<section>
|
||||
<h2 id="modal-title">Confirm action</h2>
|
||||
<p id="modal-description">Review the operation before continuing.</p>
|
||||
<button id="first-action">First</button>
|
||||
<button id="last-action">Last</button>
|
||||
</section>
|
||||
</openclaw-modal-dialog>
|
||||
`,
|
||||
container,
|
||||
);
|
||||
const modal = container.querySelector<OpenClawModalDialog>("openclaw-modal-dialog");
|
||||
expect(modal).not.toBeNull();
|
||||
await modal!.updateComplete;
|
||||
await nextFrame();
|
||||
const dialog = modal!.shadowRoot?.querySelector("dialog");
|
||||
expect(dialog).not.toBeNull();
|
||||
return { modal: modal!, dialog: dialog! };
|
||||
}
|
||||
|
||||
describe("openclaw-modal-dialog", () => {
|
||||
beforeEach(() => {
|
||||
installDialogPolyfill();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
render(nothing, container);
|
||||
container.remove();
|
||||
restoreDescriptor("showModal", showModalDescriptor);
|
||||
restoreDescriptor("close", closeDescriptor);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("opens a labelled modal dialog with an optional description", async () => {
|
||||
const { modal, dialog } = await renderModal();
|
||||
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.getAttribute("role")).toBe("dialog");
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
const labelId = dialog.getAttribute("aria-labelledby");
|
||||
const descriptionId = dialog.getAttribute("aria-describedby");
|
||||
expect(labelId).toBe("openclaw-modal-dialog-label");
|
||||
expect(descriptionId).toBe("openclaw-modal-dialog-description");
|
||||
expect(dialog.getRootNode()).toBe(modal.shadowRoot);
|
||||
expect(dialog.ownerDocument.querySelector(`#${labelId}`)).toBeNull();
|
||||
expect(modal.shadowRoot?.getElementById(labelId!)?.textContent).toBe("Confirm action");
|
||||
expect(modal.shadowRoot?.getElementById(descriptionId!)?.textContent).toBe(
|
||||
"Review the operation before continuing.",
|
||||
);
|
||||
});
|
||||
|
||||
it("focuses the dialog container first", async () => {
|
||||
const { modal, dialog } = await renderModal();
|
||||
|
||||
expect(modal.shadowRoot?.activeElement).toBe(dialog);
|
||||
expect(document.activeElement).not.toBe(container.querySelector("#first-action"));
|
||||
});
|
||||
|
||||
it("cycles Tab and Shift+Tab inside focusable dialog content", async () => {
|
||||
const { dialog } = await renderModal();
|
||||
const first = container.querySelector<HTMLButtonElement>("#first-action");
|
||||
const last = container.querySelector<HTMLButtonElement>("#last-action");
|
||||
expect(first).not.toBeNull();
|
||||
expect(last).not.toBeNull();
|
||||
|
||||
last!.focus();
|
||||
const tab = new KeyboardEvent("keydown", {
|
||||
key: "Tab",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
last!.dispatchEvent(tab);
|
||||
expect(tab.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(first);
|
||||
|
||||
first!.focus();
|
||||
const shiftTab = new KeyboardEvent("keydown", {
|
||||
key: "Tab",
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
});
|
||||
first!.dispatchEvent(shiftTab);
|
||||
expect(shiftTab.defaultPrevented).toBe(true);
|
||||
expect(document.activeElement).toBe(last);
|
||||
expect(dialog.open).toBe(true);
|
||||
});
|
||||
|
||||
it("emits modal-cancel on Escape", async () => {
|
||||
const { modal, dialog } = await renderModal();
|
||||
const onCancel = vi.fn();
|
||||
modal.addEventListener("modal-cancel", onCancel);
|
||||
|
||||
dialog.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "Escape",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("restores focus when closed and removed", async () => {
|
||||
const returnTarget = document.createElement("button");
|
||||
returnTarget.textContent = "Return";
|
||||
document.body.append(returnTarget);
|
||||
returnTarget.focus();
|
||||
|
||||
await renderModal();
|
||||
expect(document.activeElement).not.toBe(returnTarget);
|
||||
|
||||
render(nothing, container);
|
||||
await nextFrame();
|
||||
|
||||
expect(document.activeElement).toBe(returnTarget);
|
||||
returnTarget.remove();
|
||||
});
|
||||
});
|
||||
279
ui/src/ui/components/modal-dialog.ts
Normal file
279
ui/src/ui/components/modal-dialog.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { property, query } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"summary",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
|
||||
export class OpenClawModalDialog extends LitElement {
|
||||
@property() label = "";
|
||||
@property() description = "";
|
||||
|
||||
@query("dialog") private dialogElement?: HTMLDialogElement;
|
||||
@query("slot") private slotElement?: HTMLSlotElement;
|
||||
|
||||
private previouslyFocused: Element | null = null;
|
||||
private opened = false;
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
display: block;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: min(540px, calc(100vw - 48px));
|
||||
max-height: calc(100dvh - 48px);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
transform: translate(-50%, -50%);
|
||||
overflow: visible;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
dialog::backdrop {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
:host {
|
||||
padding: 12px;
|
||||
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
dialog {
|
||||
width: calc(100vw - 24px);
|
||||
max-height: 90dvh;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.previouslyFocused = this.ownerDocument.activeElement;
|
||||
}
|
||||
|
||||
override firstUpdated() {
|
||||
this.openDialog();
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
this.closeDialog();
|
||||
this.restoreFocus();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
override render() {
|
||||
const labelId = this.label ? "openclaw-modal-dialog-label" : "";
|
||||
const descriptionId = this.description ? "openclaw-modal-dialog-description" : "";
|
||||
return html`
|
||||
<dialog
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby=${ifDefined(labelId || undefined)}
|
||||
aria-describedby=${ifDefined(descriptionId || undefined)}
|
||||
tabindex="-1"
|
||||
@cancel=${this.handleCancel}
|
||||
@keydown=${this.handleKeydown}
|
||||
>
|
||||
${this.label
|
||||
? html`<span id=${labelId} class="visually-hidden">${this.label}</span>`
|
||||
: nothing}
|
||||
${this.description
|
||||
? html`<span id=${descriptionId} class="visually-hidden">${this.description}</span>`
|
||||
: nothing}
|
||||
<slot></slot>
|
||||
</dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
private openDialog() {
|
||||
if (this.opened) {
|
||||
return;
|
||||
}
|
||||
const dialog = this.dialogElement;
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
this.opened = true;
|
||||
if (typeof dialog.showModal === "function") {
|
||||
try {
|
||||
if (!dialog.open) {
|
||||
dialog.showModal();
|
||||
}
|
||||
} catch {
|
||||
if (!dialog.open) {
|
||||
dialog.setAttribute("open", "");
|
||||
}
|
||||
}
|
||||
} else if (!dialog.open) {
|
||||
dialog.setAttribute("open", "");
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (!this.isConnected || !this.dialogElement?.open) {
|
||||
return;
|
||||
}
|
||||
this.focusDialog();
|
||||
});
|
||||
}
|
||||
|
||||
private closeDialog() {
|
||||
const dialog = this.dialogElement;
|
||||
if (!dialog?.open) {
|
||||
return;
|
||||
}
|
||||
if (typeof dialog.close === "function") {
|
||||
dialog.close();
|
||||
return;
|
||||
}
|
||||
dialog.removeAttribute("open");
|
||||
}
|
||||
|
||||
private restoreFocus() {
|
||||
const target = this.previouslyFocused;
|
||||
this.previouslyFocused = null;
|
||||
if (!(target instanceof HTMLElement) || !target.isConnected) {
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
if (target.isConnected) {
|
||||
target.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private focusDialog() {
|
||||
const dialog = this.dialogElement;
|
||||
if (!dialog) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
dialog.focus({ preventScroll: true });
|
||||
} catch {
|
||||
dialog.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleCancel = (event: Event) => {
|
||||
event.preventDefault();
|
||||
this.dispatchCancel();
|
||||
};
|
||||
|
||||
private handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dispatchCancel();
|
||||
return;
|
||||
}
|
||||
if (event.key === "Tab") {
|
||||
this.trapFocus(event);
|
||||
}
|
||||
};
|
||||
|
||||
private trapFocus(event: KeyboardEvent) {
|
||||
const focusable = this.getFocusableElements();
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
this.focusDialog();
|
||||
return;
|
||||
}
|
||||
const active = this.getActiveElement();
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const focusInside = active ? focusable.includes(active) : false;
|
||||
|
||||
if (event.shiftKey && (!focusInside || active === first || active === this.dialogElement)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
return;
|
||||
}
|
||||
if (!event.shiftKey && (!focusInside || active === last || active === this.dialogElement)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private getActiveElement(): HTMLElement | null {
|
||||
const active = this.ownerDocument.activeElement;
|
||||
if (active === this && this.shadowRoot?.activeElement instanceof HTMLElement) {
|
||||
return this.shadowRoot.activeElement;
|
||||
}
|
||||
return active instanceof HTMLElement ? active : null;
|
||||
}
|
||||
|
||||
private getFocusableElements(): HTMLElement[] {
|
||||
const assigned = this.slotElement?.assignedElements({ flatten: true }) ?? [];
|
||||
const focusable: HTMLElement[] = [];
|
||||
for (const element of assigned) {
|
||||
this.collectFocusable(element, focusable);
|
||||
}
|
||||
return focusable.filter((element) => this.isFocusable(element));
|
||||
}
|
||||
|
||||
private collectFocusable(element: Element, output: HTMLElement[]) {
|
||||
if (element instanceof HTMLElement && element.matches(FOCUSABLE_SELECTOR)) {
|
||||
output.push(element);
|
||||
}
|
||||
for (const child of element.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)) {
|
||||
output.push(child);
|
||||
}
|
||||
}
|
||||
|
||||
private isFocusable(element: HTMLElement): boolean {
|
||||
if (element.closest("[hidden], [inert]")) {
|
||||
return false;
|
||||
}
|
||||
if (element.tabIndex < 0) {
|
||||
return false;
|
||||
}
|
||||
return element.isConnected;
|
||||
}
|
||||
|
||||
private dispatchCancel() {
|
||||
this.dispatchEvent(new CustomEvent("modal-cancel", { bubbles: true, composed: true }));
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("openclaw-modal-dialog")) {
|
||||
customElements.define("openclaw-modal-dialog", OpenClawModalDialog);
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"openclaw-modal-dialog": OpenClawModalDialog;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import "../components/modal-dialog.ts";
|
||||
|
||||
type DreamingRestartConfirmationProps = {
|
||||
open: boolean;
|
||||
@@ -13,14 +14,23 @@ export function renderDreamingRestartConfirmation(props: DreamingRestartConfirma
|
||||
if (!props.open) {
|
||||
return nothing;
|
||||
}
|
||||
const titleId = "dreaming-restart-confirmation-title";
|
||||
const descriptionId = "dreaming-restart-confirmation-description";
|
||||
const title = t("dreaming.restartConfirmation.title");
|
||||
const description = t("dreaming.restartConfirmation.subtitle");
|
||||
const handleCancel = () => {
|
||||
if (!props.loading) {
|
||||
props.onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite">
|
||||
<openclaw-modal-dialog label=${title} description=${description} @modal-cancel=${handleCancel}>
|
||||
<div class="exec-approval-card">
|
||||
<div class="exec-approval-header">
|
||||
<div>
|
||||
<div class="exec-approval-title">${t("dreaming.restartConfirmation.title")}</div>
|
||||
<div class="exec-approval-sub">${t("dreaming.restartConfirmation.subtitle")}</div>
|
||||
<div id=${titleId} class="exec-approval-title">${title}</div>
|
||||
<div id=${descriptionId} class="exec-approval-sub">${description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="callout danger" style="margin-top: 12px;">
|
||||
@@ -40,6 +50,6 @@ export function renderDreamingRestartConfirmation(props: DreamingRestartConfirma
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</openclaw-modal-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
221
ui/src/ui/views/exec-approval.test.ts
Normal file
221
ui/src/ui/views/exec-approval.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
/* @vitest-environment jsdom */
|
||||
|
||||
import { nothing, render } from "lit";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import { type OpenClawModalDialog } from "../components/modal-dialog.ts";
|
||||
import type { ExecApprovalRequest } from "../controllers/exec-approval.ts";
|
||||
import { renderDreamingRestartConfirmation } from "./dreaming-restart-confirmation.ts";
|
||||
import { renderExecApprovalPrompt } from "./exec-approval.ts";
|
||||
import { renderGatewayUrlConfirmation } from "./gateway-url-confirmation.ts";
|
||||
|
||||
let container: HTMLDivElement;
|
||||
|
||||
const showModalDescriptor = Object.getOwnPropertyDescriptor(
|
||||
HTMLDialogElement.prototype,
|
||||
"showModal",
|
||||
);
|
||||
const closeDescriptor = Object.getOwnPropertyDescriptor(HTMLDialogElement.prototype, "close");
|
||||
|
||||
function nextFrame() {
|
||||
return new Promise<void>((resolve) => {
|
||||
requestAnimationFrame(() => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
function installDialogPolyfill() {
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "showModal", {
|
||||
configurable: true,
|
||||
value(this: HTMLDialogElement) {
|
||||
this.setAttribute("open", "");
|
||||
},
|
||||
});
|
||||
Object.defineProperty(HTMLDialogElement.prototype, "close", {
|
||||
configurable: true,
|
||||
value(this: HTMLDialogElement) {
|
||||
this.removeAttribute("open");
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function restoreDescriptor(name: "showModal" | "close", descriptor?: PropertyDescriptor) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(HTMLDialogElement.prototype, name, descriptor);
|
||||
return;
|
||||
}
|
||||
delete (HTMLDialogElement.prototype as Partial<HTMLDialogElement>)[name];
|
||||
}
|
||||
|
||||
async function getRenderedDialog() {
|
||||
const modal = container.querySelector<OpenClawModalDialog>("openclaw-modal-dialog");
|
||||
expect(modal).not.toBeNull();
|
||||
await modal!.updateComplete;
|
||||
await nextFrame();
|
||||
const dialog = modal!.shadowRoot?.querySelector("dialog");
|
||||
expect(dialog).not.toBeNull();
|
||||
return { modal: modal!, dialog: dialog! };
|
||||
}
|
||||
|
||||
function dispatchEscape(target: EventTarget) {
|
||||
target.dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "Escape",
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
composed: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function createExecRequest(): ExecApprovalRequest {
|
||||
return {
|
||||
id: "approval-1",
|
||||
kind: "exec",
|
||||
request: {
|
||||
command: "echo hello",
|
||||
host: "gateway",
|
||||
cwd: "/tmp/openclaw",
|
||||
security: "workspace-write",
|
||||
ask: "on-request",
|
||||
},
|
||||
createdAtMs: Date.now() - 1_000,
|
||||
expiresAtMs: Date.now() + 60_000,
|
||||
};
|
||||
}
|
||||
|
||||
function createExecState(
|
||||
overrides: Partial<
|
||||
Pick<
|
||||
AppViewState,
|
||||
"execApprovalBusy" | "execApprovalError" | "execApprovalQueue" | "handleExecApprovalDecision"
|
||||
>
|
||||
> = {},
|
||||
): AppViewState {
|
||||
return {
|
||||
execApprovalQueue: [createExecRequest()],
|
||||
execApprovalBusy: false,
|
||||
execApprovalError: null,
|
||||
handleExecApprovalDecision: vi.fn(async () => undefined),
|
||||
...overrides,
|
||||
} as unknown as AppViewState;
|
||||
}
|
||||
|
||||
describe("approval and confirmation modals", () => {
|
||||
beforeEach(() => {
|
||||
installDialogPolyfill();
|
||||
container = document.createElement("div");
|
||||
document.body.append(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
render(nothing, container);
|
||||
container.remove();
|
||||
restoreDescriptor("showModal", showModalDescriptor);
|
||||
restoreDescriptor("close", closeDescriptor);
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders exec approval as a labelled modal", async () => {
|
||||
render(renderExecApprovalPrompt(createExecState()), container);
|
||||
|
||||
const { modal, dialog } = await getRenderedDialog();
|
||||
|
||||
expect(dialog.getAttribute("aria-modal")).toBe("true");
|
||||
expect(dialog.getAttribute("aria-labelledby")).toBe("openclaw-modal-dialog-label");
|
||||
expect(dialog.getAttribute("aria-describedby")).toBe("openclaw-modal-dialog-description");
|
||||
expect(modal.shadowRoot?.querySelector("#openclaw-modal-dialog-label")?.textContent).toBe(
|
||||
"Exec approval needed",
|
||||
);
|
||||
expect(
|
||||
modal.shadowRoot?.querySelector("#openclaw-modal-dialog-description")?.textContent,
|
||||
).toContain("expires in");
|
||||
expect(container.querySelector("#exec-approval-title")?.textContent).toContain(
|
||||
"Exec approval needed",
|
||||
);
|
||||
});
|
||||
|
||||
it("maps Escape to exec denial when approval is idle", async () => {
|
||||
const handleExecApprovalDecision = vi.fn(async () => undefined);
|
||||
render(renderExecApprovalPrompt(createExecState({ handleExecApprovalDecision })), container);
|
||||
|
||||
const { dialog } = await getRenderedDialog();
|
||||
dispatchEscape(dialog);
|
||||
|
||||
expect(handleExecApprovalDecision).toHaveBeenCalledTimes(1);
|
||||
expect(handleExecApprovalDecision).toHaveBeenCalledWith("deny");
|
||||
});
|
||||
|
||||
it("does not dispatch an extra exec decision from Escape while busy", async () => {
|
||||
const handleExecApprovalDecision = vi.fn(async () => undefined);
|
||||
render(
|
||||
renderExecApprovalPrompt(
|
||||
createExecState({ execApprovalBusy: true, handleExecApprovalDecision }),
|
||||
),
|
||||
container,
|
||||
);
|
||||
|
||||
const { dialog } = await getRenderedDialog();
|
||||
dispatchEscape(dialog);
|
||||
|
||||
expect(handleExecApprovalDecision).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the shared modal primitive for gateway URL confirmation and cancels on Escape", async () => {
|
||||
const handleGatewayUrlCancel = vi.fn();
|
||||
render(
|
||||
renderGatewayUrlConfirmation({
|
||||
pendingGatewayUrl: "wss://gateway.example/openclaw",
|
||||
handleGatewayUrlConfirm: vi.fn(),
|
||||
handleGatewayUrlCancel,
|
||||
} as unknown as AppViewState),
|
||||
container,
|
||||
);
|
||||
|
||||
const { dialog } = await getRenderedDialog();
|
||||
expect(container.querySelector("openclaw-modal-dialog")).not.toBeNull();
|
||||
|
||||
dispatchEscape(dialog);
|
||||
|
||||
expect(handleGatewayUrlCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses the shared modal primitive for dreaming restart confirmation and cancels on Escape", async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
renderDreamingRestartConfirmation({
|
||||
open: true,
|
||||
loading: false,
|
||||
onConfirm: vi.fn(),
|
||||
onCancel,
|
||||
hasError: false,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const { dialog } = await getRenderedDialog();
|
||||
expect(container.querySelector("openclaw-modal-dialog")).not.toBeNull();
|
||||
|
||||
dispatchEscape(dialog);
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not cancel dreaming restart from Escape while loading", async () => {
|
||||
const onCancel = vi.fn();
|
||||
render(
|
||||
renderDreamingRestartConfirmation({
|
||||
open: true,
|
||||
loading: true,
|
||||
onConfirm: vi.fn(),
|
||||
onCancel,
|
||||
hasError: false,
|
||||
}),
|
||||
container,
|
||||
);
|
||||
|
||||
const { dialog } = await getRenderedDialog();
|
||||
dispatchEscape(dialog);
|
||||
|
||||
expect(onCancel).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { formatApprovalDisplayPath } from "../../../../src/infra/approval-display-paths.ts";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import "../components/modal-dialog.ts";
|
||||
import type {
|
||||
ExecApprovalRequest,
|
||||
ExecApprovalRequestPayload,
|
||||
@@ -73,13 +74,20 @@ export function renderExecApprovalPrompt(state: AppViewState) {
|
||||
const title = isPlugin
|
||||
? (active.pluginTitle ?? "Plugin approval needed")
|
||||
: "Exec approval needed";
|
||||
const titleId = "exec-approval-title";
|
||||
const descriptionId = "exec-approval-description";
|
||||
const handleCancel = () => {
|
||||
if (!state.execApprovalBusy) {
|
||||
void state.handleExecApprovalDecision("deny");
|
||||
}
|
||||
};
|
||||
return html`
|
||||
<div class="exec-approval-overlay" role="dialog" aria-live="polite">
|
||||
<openclaw-modal-dialog label=${title} description=${remaining} @modal-cancel=${handleCancel}>
|
||||
<div class="exec-approval-card">
|
||||
<div class="exec-approval-header">
|
||||
<div>
|
||||
<div class="exec-approval-title">${title}</div>
|
||||
<div class="exec-approval-sub">${remaining}</div>
|
||||
<div id=${titleId} class="exec-approval-title">${title}</div>
|
||||
<div id=${descriptionId} class="exec-approval-sub">${remaining}</div>
|
||||
</div>
|
||||
${queueCount > 1
|
||||
? html`<div class="exec-approval-queue">${queueCount} pending</div>`
|
||||
@@ -113,6 +121,6 @@ export function renderExecApprovalPrompt(state: AppViewState) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</openclaw-modal-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,29 @@
|
||||
import { html, nothing } from "lit";
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import type { AppViewState } from "../app-view-state.ts";
|
||||
import "../components/modal-dialog.ts";
|
||||
|
||||
export function renderGatewayUrlConfirmation(state: AppViewState) {
|
||||
const { pendingGatewayUrl } = state;
|
||||
if (!pendingGatewayUrl) {
|
||||
return nothing;
|
||||
}
|
||||
const titleId = "gateway-url-confirmation-title";
|
||||
const descriptionId = "gateway-url-confirmation-description";
|
||||
const title = t("channels.gatewayUrlConfirmation.title");
|
||||
const description = t("channels.gatewayUrlConfirmation.subtitle");
|
||||
|
||||
return html`
|
||||
<div class="exec-approval-overlay" role="dialog" aria-modal="true" aria-live="polite">
|
||||
<openclaw-modal-dialog
|
||||
label=${title}
|
||||
description=${description}
|
||||
@modal-cancel=${() => state.handleGatewayUrlCancel()}
|
||||
>
|
||||
<div class="exec-approval-card">
|
||||
<div class="exec-approval-header">
|
||||
<div>
|
||||
<div class="exec-approval-title">${t("channels.gatewayUrlConfirmation.title")}</div>
|
||||
<div class="exec-approval-sub">${t("channels.gatewayUrlConfirmation.subtitle")}</div>
|
||||
<div id=${titleId} class="exec-approval-title">${title}</div>
|
||||
<div id=${descriptionId} class="exec-approval-sub">${description}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exec-approval-command mono">${pendingGatewayUrl}</div>
|
||||
@@ -30,6 +39,6 @@ export function renderGatewayUrlConfirmation(state: AppViewState) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</openclaw-modal-dialog>
|
||||
`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user