From 72adefbca06c6eea7a17ed03373e4747c4420d79 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 25 Aug 2026 19:42:45 -0700 Subject: [PATCH] fix(ui): keep modal keyboard ownership across shadow roots (#129747) --- ui/src/app/app-host-native-shell.test.ts | 37 ++++++++++++ ui/src/app/app-shell-chrome.ts | 2 +- ui/src/components/modal-dialog.test.ts | 14 ++++- ui/src/components/modal-dialog.ts | 14 ++--- .../e2e/channels-whatsapp-logout.e2e.test.ts | 15 ++++- ui/src/lib/toast.ts | 4 +- ui/src/pages/chat/chat-pane-lifecycle.ts | 4 +- ui/src/pages/chat/chat-pane-shared.ts | 1 - ui/src/pages/chat/chat-pane.test.ts | 57 +++++++++++++++++++ 9 files changed, 132 insertions(+), 16 deletions(-) diff --git a/ui/src/app/app-host-native-shell.test.ts b/ui/src/app/app-host-native-shell.test.ts index cf417fcaa358..bc087563dbdc 100644 --- a/ui/src/app/app-host-native-shell.test.ts +++ b/ui/src/app/app-host-native-shell.test.ts @@ -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 }) => { diff --git a/ui/src/app/app-shell-chrome.ts b/ui/src/app/app-shell-chrome.ts index e711c3df6165..8c3a8e1c7b17 100644 --- a/ui/src/app/app-shell-chrome.ts +++ b/ui/src/app/app-shell-chrome.ts @@ -486,7 +486,7 @@ export class ShellChromeOwner { ?.isOpen || overlaySnapshot?.devicePairSetupOpen || host.approvalOverlay?.dialogOpen === true || - document.querySelector("dialog[open]") + document.openClawModalLayers?.size ) { return true; } diff --git a/ui/src/components/modal-dialog.test.ts b/ui/src/components/modal-dialog.test.ts index 20b7657f61ab..a826739eb463 100644 --- a/ui/src/components/modal-dialog.test.ts +++ b/ui/src/components/modal-dialog.test.ts @@ -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 () => { diff --git a/ui/src/components/modal-dialog.ts b/ui/src/components/modal-dialog.ts index 3f44f9ebf9df..868ba6e81484 100644 --- a/ui/src/components/modal-dialog.ts +++ b/ui/src/components/modal-dialog.ts @@ -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()); +const modalLayers = (document.openClawModalLayers ??= new Set()); -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) { 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; + openClawModalLayers?: Set; } interface HTMLElementTagNameMap { diff --git a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts index d0c5e78ccf00..058d80bff09c 100644 --- a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts +++ b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts @@ -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); diff --git a/ui/src/lib/toast.ts b/ui/src/lib/toast.ts index 2b7fb207fc37..5a0474b4623a 100644 --- a/ui/src/lib/toast.ts +++ b/ui/src/lib/toast.ts @@ -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) diff --git a/ui/src/pages/chat/chat-pane-lifecycle.ts b/ui/src/pages/chat/chat-pane-lifecycle.ts index cf2e5428ea0c..b6662b5566b1 100644 --- a/ui/src/pages/chat/chat-pane-lifecycle.ts +++ b/ui/src/pages/chat/chat-pane-lifecycle.ts @@ -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(CHAT_COMPOSER_TEXTAREA_SELECTOR); if (composer && !composer.disabled && !composer.readOnly) { diff --git a/ui/src/pages/chat/chat-pane-shared.ts b/ui/src/pages/chat/chat-pane-shared.ts index cea0cb0308d8..9143b2420147 100644 --- a/ui/src/pages/chat/chat-pane-shared.ts +++ b/ui/src/pages/chat/chat-pane-shared.ts @@ -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."; diff --git a/ui/src/pages/chat/chat-pane.test.ts b/ui/src/pages/chat/chat-pane.test.ts index 93fc6e0b28c2..5f53fa01b313 100644 --- a/ui/src/pages/chat/chat-pane.test.ts +++ b/ui/src/pages/chat/chat-pane.test.ts @@ -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();