mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 04:47:03 -06:00
fix(ui): keep modal keyboard ownership across shadow roots (#129747)
This commit is contained in:
committed by
GitHub
parent
34d5fd1d78
commit
72adefbca0
@@ -3,6 +3,7 @@
|
||||
import { render } from "lit";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import "../components/sidebar-update-card.ts";
|
||||
import { getRenderedModalDialog, installDialogPolyfill } from "../test-helpers/modal-dialog.ts";
|
||||
import "./app-host.ts";
|
||||
import { resetAppHostTestGlobals, type ShellKeyboardState } from "./app-host.test-support.ts";
|
||||
import type { ApplicationContext } from "./context.ts";
|
||||
@@ -149,6 +150,42 @@ describe("OpenClaw native shell", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("lets a shadow-root confirmation own Escape without leaving Settings", async () => {
|
||||
const restoreDialogPolyfill = installDialogPolyfill();
|
||||
const navigate = vi.fn();
|
||||
const shell = document.createElement(
|
||||
"openclaw-app-shell",
|
||||
) as unknown as ShellSettingsEscapeState;
|
||||
shell.runtime = {
|
||||
context: {
|
||||
navigate,
|
||||
overlays: { snapshot: { devicePairSetupOpen: false } },
|
||||
} as unknown as ApplicationContext,
|
||||
};
|
||||
shell.lastWorkspaceLocation = { routeId: "usage", pathname: "/usage", search: "" };
|
||||
shell.navDrawerOpen = false;
|
||||
shell.routeState = { routeId: "appearance" };
|
||||
const container = document.body.appendChild(document.createElement("div"));
|
||||
const modal = container.appendChild(document.createElement("openclaw-modal-dialog"));
|
||||
const cancel = modal.appendChild(document.createElement("button"));
|
||||
|
||||
try {
|
||||
const { dialog } = await getRenderedModalDialog(container);
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(document.querySelector("dialog[open]")).toBeNull();
|
||||
cancel.addEventListener("keydown", (event) => shell.handleDocumentKeydown(event));
|
||||
const event = new KeyboardEvent("keydown", { key: "Escape", cancelable: true });
|
||||
|
||||
cancel.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(navigate).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
container.remove();
|
||||
restoreDialogPolyfill();
|
||||
}
|
||||
});
|
||||
|
||||
it("toggles the navigation sidebar when the native macOS titlebar button fires", () => {
|
||||
const snapshot = { navCollapsed: false };
|
||||
const update = vi.fn((next: { navCollapsed: boolean }) => {
|
||||
|
||||
@@ -486,7 +486,7 @@ export class ShellChromeOwner {
|
||||
?.isOpen ||
|
||||
overlaySnapshot?.devicePairSetupOpen ||
|
||||
host.approvalOverlay?.dialogOpen === true ||
|
||||
document.querySelector("dialog[open]")
|
||||
document.openClawModalLayers?.size
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -48,7 +48,7 @@ describe("openclaw-modal-dialog", () => {
|
||||
});
|
||||
|
||||
it("opens a labelled modal dialog with an optional description", async () => {
|
||||
const { webAwesomeDialog, dialog } = await renderModal();
|
||||
const { modal, webAwesomeDialog, dialog } = await renderModal();
|
||||
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(dialog.localName).toBe("dialog");
|
||||
@@ -57,6 +57,18 @@ describe("openclaw-modal-dialog", () => {
|
||||
expect(dialog.getAttribute("aria-label")).toBe("Confirm action");
|
||||
expect(dialog.getAttribute("aria-description")).toBe("Review the operation before continuing.");
|
||||
expect(dialog.getRootNode()).toBe(webAwesomeDialog.shadowRoot);
|
||||
expect(document.openClawModalLayers?.has(modal)).toBe(true);
|
||||
|
||||
modal.hide();
|
||||
await modal.updateComplete;
|
||||
expect(document.openClawModalLayers?.has(modal)).toBe(false);
|
||||
|
||||
modal.show();
|
||||
await modal.updateComplete;
|
||||
expect(document.openClawModalLayers?.has(modal)).toBe(true);
|
||||
|
||||
modal.remove();
|
||||
expect(document.openClawModalLayers?.has(modal)).toBe(false);
|
||||
});
|
||||
|
||||
it("focuses the dialog container first", async () => {
|
||||
|
||||
@@ -5,12 +5,12 @@ import { css, html, type PropertyValues } from "lit";
|
||||
import { property, query } from "lit/decorators.js";
|
||||
import { OpenClawLitElement } from "../lit/openclaw-element.ts";
|
||||
|
||||
const modalToastLayers = (document.openClawModalToastLayers ??= new Set<HTMLElement>());
|
||||
const modalLayers = (document.openClawModalLayers ??= new Set<HTMLElement>());
|
||||
|
||||
function setModalToastLayer(modal: HTMLElement, open: boolean) {
|
||||
modalToastLayers.delete(modal);
|
||||
function setModalLayer(modal: HTMLElement, open: boolean) {
|
||||
modalLayers.delete(modal);
|
||||
if (open) {
|
||||
modalToastLayers.add(modal);
|
||||
modalLayers.add(modal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ export class OpenClawModalDialog extends OpenClawLitElement {
|
||||
}
|
||||
|
||||
override disconnectedCallback() {
|
||||
setModalToastLayer(this, false);
|
||||
setModalLayer(this, false);
|
||||
this.syncGeneration += 1;
|
||||
const webAwesomeDialog = this.webAwesomeDialog;
|
||||
const dialog = webAwesomeDialog?.shadowRoot?.querySelector("dialog");
|
||||
@@ -182,7 +182,7 @@ export class OpenClawModalDialog extends OpenClawLitElement {
|
||||
|
||||
protected override updated(changed: PropertyValues<this>) {
|
||||
if (changed.has("open")) {
|
||||
setModalToastLayer(this, this.open);
|
||||
setModalLayer(this, this.open);
|
||||
}
|
||||
void this.syncAccessibility();
|
||||
void this.syncDialogOpen();
|
||||
@@ -330,7 +330,7 @@ if (!customElements.get("openclaw-modal-dialog")) {
|
||||
|
||||
declare global {
|
||||
interface Document {
|
||||
openClawModalToastLayers?: Set<HTMLElement>;
|
||||
openClawModalLayers?: Set<HTMLElement>;
|
||||
}
|
||||
|
||||
interface HTMLElementTagNameMap {
|
||||
|
||||
@@ -204,7 +204,20 @@ suite.define(() => {
|
||||
await expect(firstConfirm.textContent()).resolves.toContain(
|
||||
"Logging out of account default stops its listener and deletes its saved credentials.",
|
||||
);
|
||||
await firstConfirm.getByRole("button", { name: "Cancel" }).click();
|
||||
await firstConfirm.getByRole("button", { name: "Cancel" }).focus();
|
||||
await page.keyboard.press("Escape");
|
||||
if (captureUiProofEnabled) {
|
||||
await mkdir(uiProofArtifactDir, { recursive: true });
|
||||
await page.screenshot({
|
||||
animations: "disabled",
|
||||
fullPage: true,
|
||||
path: path.join(
|
||||
uiProofArtifactDir,
|
||||
`modal-escape-${process.env.OPENCLAW_UI_PROOF_LABEL ?? "dismissed"}.png`,
|
||||
),
|
||||
});
|
||||
}
|
||||
await expect.poll(() => new URL(page.url()).pathname).toBe("/settings/channels");
|
||||
await expect.poll(() => page.locator("openclaw-modal-dialog").count()).toBe(1);
|
||||
await expect.poll(async () => gateway.getRequests("channels.logout")).toHaveLength(0);
|
||||
await expect(qr.getAttribute("src")).resolves.toBe(QR_DATA_URL);
|
||||
|
||||
+1
-3
@@ -26,9 +26,7 @@ const DEFAULT_TOAST_DURATION_MS = 6_000;
|
||||
const TOAST_EXIT_FALLBACK_MS = 450;
|
||||
|
||||
function activeModalToastLayer() {
|
||||
return [...(document.openClawModalToastLayers ?? [])].findLast(
|
||||
(candidate) => candidate.isConnected,
|
||||
);
|
||||
return [...(document.openClawModalLayers ?? [])].findLast((candidate) => candidate.isConnected);
|
||||
}
|
||||
|
||||
// Outcomes reported during startup (a restored post-update result, for example)
|
||||
|
||||
@@ -46,7 +46,6 @@ import { ChatPaneSessionPanelToggleController } from "./chat-pane-session-panel-
|
||||
import {
|
||||
CHAT_AUTOTYPE_EXEMPT_SELECTOR,
|
||||
CHAT_COMPOSER_TEXTAREA_SELECTOR,
|
||||
CHAT_MODAL_SELECTOR,
|
||||
CHAT_OPEN_DETAILS_SELECTOR,
|
||||
CHAT_SPACE_ACTIVATION_SELECTOR,
|
||||
keyboardEventPathMatches,
|
||||
@@ -351,7 +350,8 @@ export abstract class ChatPaneLifecycle extends ChatPaneSessionCreation {
|
||||
event.key.length === 1 &&
|
||||
!keyboardEventPathMatches(event, CHAT_AUTOTYPE_EXEMPT_SELECTOR) &&
|
||||
!(event.key === " " && keyboardEventPathMatches(event, CHAT_SPACE_ACTIVATION_SELECTOR)) &&
|
||||
!document.querySelector(CHAT_MODAL_SELECTOR)
|
||||
!document.openClawModalLayers?.size &&
|
||||
!document.querySelector("[aria-modal='true']")
|
||||
) {
|
||||
const composer = this.querySelector<HTMLTextAreaElement>(CHAT_COMPOSER_TEXTAREA_SELECTOR);
|
||||
if (composer && !composer.disabled && !composer.readOnly) {
|
||||
|
||||
@@ -239,7 +239,6 @@ export const CHAT_AUTOTYPE_EXEMPT_SELECTOR =
|
||||
"input, textarea, select, [contenteditable]:not([contenteditable='false']), [role='combobox'], [role='listbox'], [role='textbox'], [data-chat-autotype-exempt]";
|
||||
export const CHAT_SPACE_ACTIVATION_SELECTOR =
|
||||
"a[href], button, summary, [role='button'], [role='checkbox'], [role='link'], [role='radio'], [role='switch']";
|
||||
export const CHAT_MODAL_SELECTOR = "dialog[open], [aria-modal='true']";
|
||||
|
||||
export const NEW_SESSION_ACTIVE_RUN_MESSAGE =
|
||||
"Start a new session after the active run or queued messages finish.";
|
||||
|
||||
@@ -8,6 +8,7 @@ import { createInitialUserMessageHandoff } from "../../app/initial-user-message-
|
||||
import { t } from "../../i18n/index.ts";
|
||||
import { showToast } from "../../lib/toast.ts";
|
||||
import {
|
||||
getRenderedModalDialog,
|
||||
installDialogPolyfill,
|
||||
waitForConfirmDialogActions,
|
||||
} from "../../test-helpers/modal-dialog.ts";
|
||||
@@ -693,6 +694,62 @@ describe("chat pane initialization", () => {
|
||||
});
|
||||
|
||||
describe("chat pane keyboard shortcuts", () => {
|
||||
it("does not steal typing focus from a shadow-root confirmation", async () => {
|
||||
const restoreDialogPolyfill = installDialogPolyfill();
|
||||
const { pane } = createTestChatPane({
|
||||
client: createGatewayBrowserClientFixture(),
|
||||
sessions: createSessionCapabilityFixture(),
|
||||
});
|
||||
pane.active = true;
|
||||
pane.presented = true;
|
||||
const composer = document.createElement("div");
|
||||
composer.className = "agent-chat__composer-combobox";
|
||||
const textarea = composer.appendChild(document.createElement("textarea"));
|
||||
pane.append(composer);
|
||||
const focus = vi.spyOn(textarea, "focus");
|
||||
const container = document.body.appendChild(document.createElement("div"));
|
||||
const modal = container.appendChild(document.createElement("openclaw-modal-dialog"));
|
||||
const cancel = modal.appendChild(document.createElement("button"));
|
||||
|
||||
try {
|
||||
const { dialog } = await getRenderedModalDialog(container);
|
||||
expect(dialog.open).toBe(true);
|
||||
expect(document.querySelector("dialog[open]")).toBeNull();
|
||||
cancel.addEventListener("keydown", (event) => pane.handleDocumentKeydown(event));
|
||||
|
||||
cancel.dispatchEvent(new KeyboardEvent("keydown", { key: "x", cancelable: true }));
|
||||
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
container.remove();
|
||||
restoreDialogPolyfill();
|
||||
}
|
||||
});
|
||||
|
||||
it("does not steal typing focus from a light-DOM confirmation", () => {
|
||||
const { pane } = createTestChatPane({
|
||||
client: createGatewayBrowserClientFixture(),
|
||||
sessions: createSessionCapabilityFixture(),
|
||||
});
|
||||
pane.active = true;
|
||||
pane.presented = true;
|
||||
const composer = document.createElement("div");
|
||||
composer.className = "agent-chat__composer-combobox";
|
||||
const textarea = composer.appendChild(document.createElement("textarea"));
|
||||
pane.append(composer);
|
||||
const focus = vi.spyOn(textarea, "focus");
|
||||
const modal = document.body.appendChild(document.createElement("div"));
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
|
||||
try {
|
||||
pane.handleDocumentKeydown(new KeyboardEvent("keydown", { key: "x", cancelable: true }));
|
||||
|
||||
expect(focus).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
modal.remove();
|
||||
}
|
||||
});
|
||||
|
||||
it("toggles only the active pane's session workspace", () => {
|
||||
const client = createGatewayBrowserClientFixture();
|
||||
const sessions = createSessionCapabilityFixture();
|
||||
|
||||
Reference in New Issue
Block a user