Merge remote-tracking branch 'origin/audit/macos-accessibility-followup' into audit/macos-accessibility-land-116026

* origin/audit/macos-accessibility-followup:
  fix(ui): dismiss picker after focus leaves widget
  fix(ui): keep picker trigger inside focus boundary
  test(ui): split terminal accessibility coverage
  test(ui): cover terminal picker pointer paths
  chore: leave accessibility notes to release prep
  fix(ui): type terminal shadow focus checks
  fix(ui): make terminal session picker keyboard accessible
This commit is contained in:
Vincent Koc
2026-07-30 04:16:32 +08:00
3 changed files with 282 additions and 5 deletions

View File

@@ -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 <T>(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<HTMLButtonElement>(
'[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<HTMLButtonElement>(
'[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 <T>(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<HTMLButtonElement>(
'[aria-label="Terminal sessions"]',
)!;
trigger.click();
await waitForFast(() =>
expect(panel.renderRoot.querySelector(".tp-session")?.textContent).toContain(
"detached-agent",
),
);
const refresh = panel.renderRoot.querySelector<HTMLButtonElement>(".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<HTMLButtonElement>(".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" },
}),
);
});
});

View File

@@ -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<HTMLButtonElement>(".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<HTMLButtonElement>('[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<void> {
@@ -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),
});

View File

@@ -8,19 +8,25 @@ type TerminalSessionPickerProps = {
sessions: TerminalSessionInfo[];
currentSessionIds: ReadonlySet<string>;
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`
<div class="tp-session-picker">
<div class="tp-session-picker" @focusout=${props.onFocusOut}>
<button
class="tp-icon"
type="button"
title=${t("terminal.sessions")}
aria-label=${t("terminal.sessions")}
aria-expanded=${props.open ? "true" : "false"}
aria-haspopup="dialog"
aria-controls=${TERMINAL_SESSION_PICKER_ID}
@click=${props.onToggle}
>
<svg
@@ -37,7 +43,20 @@ export function renderTerminalSessionPicker(props: TerminalSessionPickerProps) {
</svg>
</button>
${props.open
? html`<div class="tp-session-menu" role="dialog" aria-label=${t("terminal.sessions")}>
? html`<div
id=${TERMINAL_SESSION_PICKER_ID}
class="tp-session-menu"
role="dialog"
aria-label=${t("terminal.sessions")}
@keydown=${(event: KeyboardEvent) => {
if (event.key !== "Escape") {
return;
}
event.preventDefault();
event.stopPropagation();
props.onDismiss(true);
}}
>
<div class="tp-session-menu__header">
<span>${t("terminal.sessions")}</span>
<button class="tp-session-refresh" type="button" @click=${props.onRefresh}>