fix(ui): keep modal keyboard ownership across shadow roots (#129747)

This commit is contained in:
Peter Steinberger
2026-08-25 19:42:45 -07:00
committed by GitHub
parent 34d5fd1d78
commit 72adefbca0
9 changed files with 132 additions and 16 deletions
+37
View File
@@ -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 }) => {
+1 -1
View File
@@ -486,7 +486,7 @@ export class ShellChromeOwner {
?.isOpen ||
overlaySnapshot?.devicePairSetupOpen ||
host.approvalOverlay?.dialogOpen === true ||
document.querySelector("dialog[open]")
document.openClawModalLayers?.size
) {
return true;
}
+13 -1
View File
@@ -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 () => {
+7 -7
View File
@@ -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
View File
@@ -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)
+2 -2
View File
@@ -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) {
-1
View File
@@ -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.";
+57
View File
@@ -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();