diff --git a/ui/src/components/terminal/terminal-panel-accessibility.test.ts b/ui/src/components/terminal/terminal-panel-accessibility.test.ts new file mode 100644 index 000000000000..9cb67f2e1074 --- /dev/null +++ b/ui/src/components/terminal/terminal-panel-accessibility.test.ts @@ -0,0 +1,200 @@ +/* @vitest-environment jsdom */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { i18n } from "../../i18n/index.ts"; +import { createStorageMock } from "../../test-helpers/storage.ts"; +import { waitForFast } from "../../test-helpers/wait-for.ts"; +import type { TerminalGatewayClient } from "./terminal-connection.ts"; +import { + createTerminalController, + defineTestTerminalPanelElement, + terminalOpenResult, + type CreateGhosttyTerminalMock, +} from "./terminal-panel.test-support.ts"; +import { OpenClawTerminalPanel } from "./terminal-panel.ts"; + +const createGhosttyTerminalMock: CreateGhosttyTerminalMock = vi.fn(); +const TERMINAL_PANEL_ELEMENT_NAME = defineTestTerminalPanelElement(createGhosttyTerminalMock); + +function createPanel(client: TerminalGatewayClient) { + const panel = document.createElement(TERMINAL_PANEL_ELEMENT_NAME) as OpenClawTerminalPanel; + panel.client = client; + panel.available = true; + document.body.append(panel); + panel.toggle(); + return panel; +} + +function createPickerClient() { + return { + forceReconnect: () => {}, + request: async (method: string) => + (method === "terminal.open" + ? terminalOpenResult("current-1") + : method === "terminal.list" + ? { sessions: [] } + : {}) as T, + addEventListener: () => () => {}, + } satisfies TerminalGatewayClient; +} + +describe("OpenClawTerminalPanel accessibility", () => { + beforeEach(async () => { + vi.stubGlobal("localStorage", createStorageMock()); + vi.stubGlobal("sessionStorage", createStorageMock()); + createGhosttyTerminalMock.mockResolvedValue(createTerminalController()); + await i18n.setLocale("en"); + }); + + afterEach(async () => { + document.body.replaceChildren(); + localStorage.clear(); + sessionStorage.clear(); + createGhosttyTerminalMock.mockReset(); + vi.unstubAllGlobals(); + await i18n.setLocale("en"); + }); + + it("manages focus and dismissal for the terminal session picker", async () => { + const panel = createPanel(createPickerClient()); + await waitForFast(() => expect(panel.renderRoot.querySelector(".tp-actions")).not.toBeNull()); + + const trigger = panel.renderRoot.querySelector( + '[aria-label="Terminal sessions"]', + ); + expect(trigger?.getAttribute("aria-haspopup")).toBe("dialog"); + expect(trigger?.getAttribute("aria-controls")).toBe("terminal-session-picker-dialog"); + + trigger?.click(); + await waitForFast(() => + expect(panel.shadowRoot?.activeElement).toBe( + panel.renderRoot.querySelector(".tp-session-refresh"), + ), + ); + expect(panel.renderRoot.querySelector(".tp-session-menu")?.getAttribute("role")).toBe("dialog"); + + panel.renderRoot + .querySelector(".tp-session-refresh") + ?.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true })); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-session-menu")).toBeNull(); + expect(panel.shadowRoot?.activeElement).toBe(trigger); + + trigger?.click(); + await waitForFast(() => + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(), + ); + document.body.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true })); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-session-menu")).toBeNull(); + }); + + it("keeps trigger focus inside the picker and dismisses after focus leaves", async () => { + const panel = createPanel(createPickerClient()); + await waitForFast(() => expect(panel.renderRoot.querySelector(".tp-actions")).not.toBeNull()); + + const trigger = panel.renderRoot.querySelector( + '[aria-label="Terminal sessions"]', + )!; + trigger.click(); + await waitForFast(() => + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(), + ); + trigger.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true })); + trigger.focus(); + await Promise.resolve(); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(); + trigger.click(); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-session-menu")).toBeNull(); + + trigger.click(); + await waitForFast(() => + expect(panel.shadowRoot?.activeElement).toBe( + panel.renderRoot.querySelector(".tp-session-refresh"), + ), + ); + trigger.focus(); + await Promise.resolve(); + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(); + + const outside = document.createElement("button"); + document.body.append(outside); + outside.focus(); + await Promise.resolve(); + await panel.updateComplete; + expect(panel.renderRoot.querySelector(".tp-session-menu")).toBeNull(); + }); + + it("keeps shadow-DOM pointer actions inside the terminal session picker", async () => { + const requests: Array<{ method: string; params: unknown }> = []; + const client: TerminalGatewayClient = { + forceReconnect: () => {}, + request: async (method: string, params?: unknown) => { + requests.push({ method, params }); + if (method === "terminal.open") { + return terminalOpenResult("current-1") as T; + } + if (method === "terminal.list") { + return { + sessions: [ + { + ...terminalOpenResult("detached-1"), + agentId: "detached-agent", + attached: false, + createdAtMs: 1, + }, + ], + } as T; + } + if (method === "terminal.attach") { + return { + ...terminalOpenResult("detached-1"), + agentId: "detached-agent", + buffer: "", + seq: 0, + } as T; + } + return {} as T; + }, + addEventListener: () => () => {}, + }; + const panel = createPanel(client); + await waitForFast(() => expect(panel.renderRoot.querySelector(".tp-actions")).not.toBeNull()); + + const trigger = panel.renderRoot.querySelector( + '[aria-label="Terminal sessions"]', + )!; + trigger.click(); + await waitForFast(() => + expect(panel.renderRoot.querySelector(".tp-session")?.textContent).toContain( + "detached-agent", + ), + ); + + const refresh = panel.renderRoot.querySelector(".tp-session-refresh")!; + refresh.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true })); + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(); + refresh.click(); + await waitForFast(() => { + expect(requests.filter(({ method }) => method === "terminal.list")).toHaveLength(2); + }); + await waitForFast(() => + expect(panel.renderRoot.querySelector(".tp-session")?.textContent).toContain( + "detached-agent", + ), + ); + + const attach = panel.renderRoot.querySelector(".tp-session")!; + attach.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, composed: true })); + expect(panel.renderRoot.querySelector(".tp-session-menu")).not.toBeNull(); + attach.click(); + await waitForFast(() => + expect(requests).toContainEqual({ + method: "terminal.attach", + params: { sessionId: "detached-1" }, + }), + ); + }); +}); diff --git a/ui/src/components/terminal/terminal-panel.ts b/ui/src/components/terminal/terminal-panel.ts index a3251fbb0d43..01b1cbaf351c 100644 --- a/ui/src/components/terminal/terminal-panel.ts +++ b/ui/src/components/terminal/terminal-panel.ts @@ -112,6 +112,8 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { }); private readonly onGlobalKeyDown = (event: KeyboardEvent) => this.handleGlobalKey(event); private readonly onToggleRequest = (event: Event) => this.handleToggleRequest(event); + private readonly onDocumentPointerDown = (event: PointerEvent) => + this.handleDocumentPointerDown(event); override connectedCallback(): void { super.connectedCallback(); @@ -123,6 +125,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { window.addEventListener("keydown", this.onGlobalKeyDown); window.addEventListener(TERMINAL_PANEL_TOGGLE_EVENT, this.onToggleRequest); } + document.addEventListener("pointerdown", this.onDocumentPointerDown, true); if (this.dockLayout.open) { void this.terminalSessions.restoreSessions(); } @@ -132,6 +135,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { super.disconnectedCallback(); window.removeEventListener("keydown", this.onGlobalKeyDown); window.removeEventListener(TERMINAL_PANEL_TOGGLE_EVENT, this.onToggleRequest); + document.removeEventListener("pointerdown", this.onDocumentPointerDown, true); this.terminalSessions.disconnectHost(); } @@ -199,6 +203,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { } closeTerminalPanel(): void { + this.closeSessionPicker(false); this.dockLayout.setOpen(false); } @@ -231,10 +236,61 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { } private toggleSessionPicker(): void { - this.sessionPickerOpen = !this.sessionPickerOpen; if (this.sessionPickerOpen) { - void this.refreshSessionPicker(); + this.closeSessionPicker(true); + return; } + this.sessionPickerOpen = true; + void this.refreshSessionPicker(); + void this.updateComplete.then(() => { + if (this.sessionPickerOpen) { + this.renderRoot.querySelector(".tp-session-refresh")?.focus(); + } + }); + } + + private closeSessionPicker(restoreFocus: boolean): void { + if (!this.sessionPickerOpen) { + return; + } + this.sessionPickerOpen = false; + if (restoreFocus) { + void this.updateComplete.then(() => { + this.renderRoot + .querySelector('[aria-controls="terminal-session-picker-dialog"]') + ?.focus(); + }); + } + } + + private handleDocumentPointerDown(event: PointerEvent): void { + if (!this.sessionPickerOpen) { + return; + } + const picker = this.renderRoot.querySelector(".tp-session-picker"); + // Document capture sees retargeted shadow-DOM events. The composed path + // preserves the picker wrapper so its trigger and actions stay clickable. + const path = event.composedPath(); + if (picker && !path.includes(picker)) { + this.closeSessionPicker(false); + } + } + + private handleSessionPickerFocusOut(event: FocusEvent): void { + const picker = event.currentTarget; + const next = event.relatedTarget; + if (picker instanceof HTMLElement && next instanceof Node && picker.contains(next)) { + return; + } + queueMicrotask(() => { + if ( + picker instanceof HTMLElement && + !picker.contains(this.shadowRoot?.activeElement ?? null) && + this.sessionPickerOpen + ) { + this.closeSessionPicker(false); + } + }); } private refreshSessionPicker(): Promise { @@ -255,7 +311,7 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { } resetTerminalSessionPicker(): void { - this.sessionPickerOpen = false; + this.closeSessionPicker(false); void this.sessionPickerTask.run([null]); this.pickerSessions = []; } @@ -293,6 +349,8 @@ export class OpenClawTerminalPanel extends OpenClawLitElement { ), ), onToggle: () => this.toggleSessionPicker(), + onDismiss: (restoreFocus) => this.closeSessionPicker(restoreFocus), + onFocusOut: (event) => this.handleSessionPickerFocusOut(event), onRefresh: () => void this.refreshSessionPicker(), onAttach: (sessionId, owner) => void this.attachPickedSession(sessionId, owner), }); diff --git a/ui/src/components/terminal/terminal-session-picker.ts b/ui/src/components/terminal/terminal-session-picker.ts index f3b38cdd9c80..5a5d48edaad8 100644 --- a/ui/src/components/terminal/terminal-session-picker.ts +++ b/ui/src/components/terminal/terminal-session-picker.ts @@ -8,19 +8,25 @@ type TerminalSessionPickerProps = { sessions: TerminalSessionInfo[]; currentSessionIds: ReadonlySet; onToggle: () => void; + onDismiss: (restoreFocus: boolean) => void; + onFocusOut: (event: FocusEvent) => void; onRefresh: () => void; onAttach: (sessionId: string, owner: TerminalSessionInfo["owner"]) => void; }; +const TERMINAL_SESSION_PICKER_ID = "terminal-session-picker-dialog"; + export function renderTerminalSessionPicker(props: TerminalSessionPickerProps) { return html` -
+
${props.open - ? html`