mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-05 01:21:35 +00:00
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:
200
ui/src/components/terminal/terminal-panel-accessibility.test.ts
Normal file
200
ui/src/components/terminal/terminal-panel-accessibility.test.ts
Normal 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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user