fix(ui): hide screen-reader-only status text (#121847)

This commit is contained in:
Vyctor H. Brzezowski
2026-08-11 13:00:44 -03:00
committed by GitHub
parent 32e3bd3797
commit 17e11e4341
20 changed files with 107 additions and 92 deletions
+2 -6
View File
@@ -146,14 +146,10 @@ suite.define(() => {
await expect
.poll(() => currentPage.locator(".chat-working-indicator__elapsed").textContent())
.toBe("2m 57s");
const workingLabel = currentPage.locator(
".chat-working-indicator__status > .agent-chat__sr-only",
);
const workingLabel = currentPage.locator(".chat-working-indicator__status > .sr-only");
expect(await workingLabel.textContent()).toBe("Working…");
expect(
await currentPage
.locator(".chat-working-indicator__status > span:not(.agent-chat__sr-only)")
.count(),
await currentPage.locator(".chat-working-indicator__status > span:not(.sr-only)").count(),
).toBe(0);
});
+2 -4
View File
@@ -113,12 +113,10 @@ describeMantisWebUiChat("Mantis Control UI web chat proof", () => {
await page.getByText("saved 875.3k tokens", { exact: true }).waitFor();
await page.locator(".chat-working-indicator").waitFor();
const workingLabel = page.locator(".chat-working-indicator__status > .agent-chat__sr-only");
const workingLabel = page.locator(".chat-working-indicator__status > .sr-only");
expect(await workingLabel.textContent()).toBe("Working…");
expect(
await page
.locator(".chat-working-indicator__status > span:not(.agent-chat__sr-only)")
.count(),
await page.locator(".chat-working-indicator__status > span:not(.sr-only)").count(),
).toBe(0);
await page.clock.fastForward(177_000);
await expect
+1 -3
View File
@@ -147,9 +147,7 @@ function renderCommit(props: AboutProps) {
<span aria-hidden="true">${props.copyState === "copied" ? icons.check : icons.copy}</span>
</button>
</openclaw-tooltip>
<span class="about-sr-only" role="status" aria-live="polite"
>${copyStatus(props.copyState)}</span
>
<span class="sr-only" role="status" aria-live="polite">${copyStatus(props.copyState)}</span>
</span>
`;
}
@@ -134,7 +134,7 @@ export function renderBackgroundTasksStatusRow(
<span class="chat-tasks-status__sep" aria-hidden="true">·</span>
`
: nothing}
<span class="agent-chat__sr-only" role="status">${label}</span>
<span class="sr-only" role="status">${label}</span>
<openclaw-tooltip class="chat-tasks-status__preview">
<button class="chat-tasks-status__link" type="button" @click=${openRail}>${label}</button>
${renderStatusPreview(remainingTasks)}
@@ -309,7 +309,7 @@ export function renderChatPrimaryActions(props: ChatRunControlsProps) {
? nothing
: html`
<span
class="agent-chat__sr-only agent-chat__voice-status"
class="sr-only agent-chat__voice-status"
role="status"
aria-live="polite"
aria-atomic="true"
@@ -410,14 +410,14 @@ export function renderChatComposerView(context: ChatComposerViewContext) {
></textarea>
<span
id=${slashMenuAnnouncementId}
class="agent-chat__sr-only"
class="sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
>${activeSlashMenuOptionLabel}</span
>
<span
class="agent-chat__run-status-announcement agent-chat__sr-only"
class="agent-chat__run-status-announcement sr-only"
role="status"
aria-live="polite"
aria-atomic="true"
@@ -1549,14 +1549,11 @@ describe("grouped chat rendering", () => {
expect(container.querySelectorAll(".chat-avatar.assistant")).toHaveLength(0);
expect(container.querySelector(".chat-reading-indicator")).not.toBeNull();
expect(container.querySelector(".chat-working-indicator__elapsed")).not.toBeNull();
expect(container.querySelector(".chat-working-indicator__status > .sr-only")?.textContent).toBe(
"Working…",
);
expect(
container.querySelector(".chat-working-indicator__status > .agent-chat__sr-only")
?.textContent,
).toBe("Working…");
expect(
container.querySelectorAll(
".chat-working-indicator__status > span:not(.agent-chat__sr-only)",
),
container.querySelectorAll(".chat-working-indicator__status > span:not(.sr-only)"),
).toHaveLength(0);
expect(container.querySelector(".chat-group-footer")).toBeNull();
});
@@ -1613,9 +1610,7 @@ describe("grouped chat rendering", () => {
label,
);
expect(container.querySelector(".chat-working-indicator__elapsed")).not.toBeNull();
expect(
container.querySelector(".chat-working-indicator__status > .agent-chat__sr-only"),
).toBeNull();
expect(container.querySelector(".chat-working-indicator__status > .sr-only")).toBeNull();
});
it("formats terminal recap durations with full localized units", () => {
@@ -1765,8 +1760,8 @@ describe("grouped chat rendering", () => {
);
const status = container.querySelector(".chat-working-indicator__status");
return {
hidden: status?.querySelector(".agent-chat__sr-only")?.textContent,
visibleLabels: status?.querySelectorAll("span:not(.agent-chat__sr-only)").length,
hidden: status?.querySelector(".sr-only")?.textContent,
visibleLabels: status?.querySelectorAll("span:not(.sr-only)").length,
// The whimsical long-wait phrase rides in its own aria-hidden element,
// never as a plain status span screen readers would announce.
decorativePhrases: status?.querySelectorAll("openclaw-working-phrase[aria-hidden]").length,
+1 -1
View File
@@ -1922,7 +1922,7 @@ function renderChatThreadContents(
@pointerup=${(event: PointerEvent) => handleChatThreadSelectionPointerUp(event, props)}
>
<span
class="chat-transcript-announcement agent-chat__sr-only"
class="chat-transcript-announcement sr-only"
role="status"
aria-live=${props.announceTranscript !== false ? "polite" : "off"}
aria-atomic="true"
@@ -163,9 +163,7 @@ export function renderChatWorkingIndicator(
${renderLiveOutputTokens(options.outputTokens)}
`
: html`
<span class=${continuation ? "" : "agent-chat__sr-only"}
>${t("common.working")}</span
>
<span class=${continuation ? "" : "sr-only"}>${t("common.working")}</span>
<openclaw-elapsed-time
class="chat-working-indicator__elapsed"
.startMs=${part.startedAt}
+1 -3
View File
@@ -270,9 +270,7 @@ function renderNewSessionComposer(options: NewSessionComposerOptions) {
</div>
</div>
${options.pendingAttachmentReads > 0
? html`<span class="agent-chat__sr-only" role="status"
>${t("newSession.readingAttachment")}</span
>`
? html`<span class="sr-only" role="status">${t("newSession.readingAttachment")}</span>`
: nothing}
</div>
</div>
+1 -3
View File
@@ -80,9 +80,7 @@ function sessionsTableHtml() {
: index === 6
? "session-actions-col"
: ""
}">${
index === 6 ? `<span class="sessions-sr-only">${header}</span>` : header
}</th>`,
}">${index === 6 ? `<span class="sr-only">${header}</span>` : header}</th>`,
)
.join("")}
</tr>
+1 -1
View File
@@ -1286,7 +1286,7 @@ function renderSessionsTable(props: SessionsProps, ctx: SessionsTableContext) {
${sortHeader("updated", t("sessionsView.updated"))}
${sortHeader("tokens", t("sessionsView.tokens"))}
<th class="session-actions-col">
<span class="sessions-sr-only">${t("sessionsView.actions")}</span>
<span class="sr-only">${t("sessionsView.actions")}</span>
</th>
</tr>
</thead>
+1 -1
View File
@@ -265,7 +265,7 @@ export function renderCardDetailsPanel(props: WorkboardProps) {
<div>
<span class="workboard-card__priority">${formatPriorityLabel(card.priority)}</span>
<h2 id=${workboardCardDetailTitleId}>
<span class="workboard-sr-only">${t("workboard.detailTitle")}: </span>${card.title}
<span class="sr-only">${t("workboard.detailTitle")}: </span>${card.title}
</h2>
</div>
<openclaw-tooltip .content=${t("common.cancel")}>
-13
View File
@@ -293,16 +293,3 @@
font-size: var(--control-ui-text-xs, 12px);
white-space: nowrap;
}
/* Screen-reader-only live region for copy feedback (no shared utility yet). */
.about-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
+13
View File
@@ -601,6 +601,19 @@
box-sizing: border-box;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0, 0, 0, 0);
clip-path: inset(50%);
white-space: nowrap;
border: 0;
}
html,
body {
height: 100%;
-12
View File
@@ -2419,18 +2419,6 @@ openclaw-chat-video-player {
min-width: 0;
}
.agent-chat__sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.agent-chat__composer-combobox > :is(textarea, input) {
width: 100%;
min-height: var(--chat-composer-control-height);
-13
View File
@@ -410,19 +410,6 @@
min-width: 760px;
}
.sessions-sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.sessions-table tbody tr.session-data-row > td {
white-space: nowrap;
}
+71
View File
@@ -0,0 +1,71 @@
import { chromium, type Browser } from "playwright";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { readStyleSheet } from "../../../test/helpers/ui-style-fixtures.js";
import {
canRunPlaywrightChromium,
resolvePlaywrightChromiumExecutablePath,
} from "../test-helpers/control-ui-e2e.ts";
const chromiumExecutablePath = resolvePlaywrightChromiumExecutablePath(chromium.executablePath());
const describeSrOnly = canRunPlaywrightChromium(chromiumExecutablePath) ? describe : describe.skip;
let browser: Browser;
beforeAll(async () => {
if (!canRunPlaywrightChromium(chromiumExecutablePath)) {
return;
}
browser = await chromium.launch({ executablePath: chromiumExecutablePath, headless: true });
});
afterAll(async () => {
await browser?.close().catch(() => {});
});
describeSrOnly("screen-reader-only utility", () => {
it("visually hides content while preserving its accessible status", async () => {
const page = await browser.newPage();
try {
await page.setContent(`<!doctype html>
<html>
<head><style>${readStyleSheet("ui/src/styles/base.css")}</style></head>
<body><span class="sr-only" role="status">Background task active</span></body>
</html>`);
const status = page.getByRole("status");
await expect
.poll(async () => {
return await status.evaluate((element) => {
const style = getComputedStyle(element);
return {
borderWidth: style.borderWidth,
clip: style.clip,
clipPath: style.clipPath,
height: style.height,
margin: style.margin,
overflow: style.overflow,
padding: style.padding,
position: style.position,
whiteSpace: style.whiteSpace,
width: style.width,
};
});
})
.toEqual({
borderWidth: "0px",
clip: "rect(0px, 0px, 0px, 0px)",
clipPath: "inset(50%)",
height: "1px",
margin: "-1px",
overflow: "hidden",
padding: "0px",
position: "absolute",
whiteSpace: "nowrap",
width: "1px",
});
expect(await status.ariaSnapshot()).toContain("Background task active");
} finally {
await page.close().catch(() => {});
}
});
});
-13
View File
@@ -34,19 +34,6 @@
min-height: 0;
}
.workboard-sr-only {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
border: 0;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.workboard-toolbar {
display: flex;
justify-content: space-between;
+1
View File
@@ -96,6 +96,7 @@ const nodeDrivenBrowserLayoutTests = [
"src/pages/sessions/view.browser.test.ts",
"src/styles/cursor-policy.browser.test.ts",
"src/styles/chat-file-link-presentation.browser.test.ts",
"src/styles/sr-only.browser.test.ts",
] as const;
const mockRegistryUnitTests = [
...uiIsolatedTestFiles.map((testFile) => testFile.slice("ui/".length)),