Skip to content

Commit 9bb1f52

Browse files
committed
fix(webapp): announce the composer's counter and name the history button
The character counter's live region only entered the DOM at the warning point, and several screen readers only announce updates for a region that was already there; it is now always mounted and empty until there is something to say. The history trigger's aria-label replaced the chat title it shows, so a speech-input user could not activate it by the words on it. The title now leads the accessible name.
1 parent 622d3d8 commit 9bb1f52

6 files changed

Lines changed: 105 additions & 3 deletions

File tree

apps/webapp/app/components/dashboard-agent/DashboardAgentComposer.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,11 @@ import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
22
import { useEffect, useRef } from "react";
33
import { Button } from "~/components/primitives/Buttons";
44
import { cn } from "~/utils/cn";
5-
import { MAX_MESSAGE_CHARS, MESSAGE_CHARS_WARN_AT } from "./message-limits";
5+
import {
6+
MAX_MESSAGE_CHARS,
7+
MESSAGE_CHARS_WARN_AT,
8+
messageCountAnnouncement,
9+
} from "./message-limits";
610

711
export type DashboardAgentComposerLayout = "docked" | "hero";
812

@@ -118,14 +122,19 @@ export function DashboardAgentComposer({
118122
)}
119123
</div>
120124
</div>
125+
{/* Mounted from the start, empty until there is something to say: a region that appears
126+
with its first message goes unannounced in several screen readers. */}
127+
<p className="sr-only" aria-live="polite">
128+
{messageCountAnnouncement(value.length)}
129+
</p>
121130
{/* Only near the limit: a normal message never sees a counter. */}
122131
{value.length >= MESSAGE_CHARS_WARN_AT ? (
123132
<p
133+
aria-hidden
124134
className={cn(
125135
"self-end text-xxs tabular-nums",
126136
value.length >= MAX_MESSAGE_CHARS ? "text-error" : "text-text-dimmed"
127137
)}
128-
aria-live="polite"
129138
>
130139
{value.length} / {MAX_MESSAGE_CHARS}
131140
</p>

apps/webapp/app/components/dashboard-agent/DashboardAgentHeader.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { Popover, PopoverArrowTrigger, PopoverContent } from "~/components/primi
77
import { ShortcutKey } from "~/components/primitives/ShortcutKey";
88
import type { Shortcut } from "~/hooks/useShortcutKeys";
99
import { DashboardAgentHistoryMenu, type DashboardAgentChat } from "./DashboardAgentHistory";
10+
import { chatHistoryTriggerLabel } from "./header-labels";
1011

1112
// Display only. The key is registered once, in `DashboardAgent`; registering it
1213
// anywhere else makes the keystroke fire twice.
@@ -59,7 +60,7 @@ export function DashboardAgentHeader({
5960
isOpen={isHistoryOpen}
6061
overflowHidden
6162
className="min-w-0"
62-
aria-label="Chat history"
63+
aria-label={chatHistoryTriggerLabel(title)}
6364
title={title}
6465
>
6566
<span className="truncate text-sm font-medium text-text-bright">{title}</span>
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { readFileSync } from "node:fs";
2+
import { describe, expect, it } from "vitest";
3+
import { chatHistoryTriggerLabel } from "./header-labels";
4+
5+
describe("chatHistoryTriggerLabel", () => {
6+
it("starts with the words on the button, so speech input can activate it", () => {
7+
const title = "Why did my task retry?";
8+
const label = chatHistoryTriggerLabel(title);
9+
expect(label.startsWith(title)).toBe(true);
10+
expect(label).toContain(title);
11+
});
12+
13+
it("still says what the button does", () => {
14+
expect(chatHistoryTriggerLabel("New chat").toLowerCase()).toContain("chat history");
15+
});
16+
17+
it("falls back to the purpose when there is no title to read", () => {
18+
expect(chatHistoryTriggerLabel("")).toBe("Chat history");
19+
expect(chatHistoryTriggerLabel(" ")).toBe("Chat history");
20+
});
21+
});
22+
23+
/**
24+
* Structural guard, not behavioural proof: the webapp has no DOM test environment, so nothing
25+
* here computes a real accessible name. It asserts the header asks for the label above rather
26+
* than a constant that would replace the visible title.
27+
*/
28+
describe("the header's history trigger", () => {
29+
const source = readFileSync(new URL("./DashboardAgentHeader.tsx", import.meta.url), "utf8");
30+
31+
it("names itself with the title, not with a bare constant", () => {
32+
expect(source).toContain("aria-label={chatHistoryTriggerLabel(title)}");
33+
expect(source).not.toContain('aria-label="Chat history"');
34+
});
35+
});
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
/**
2+
* The chat-history trigger shows the chat's title and opens the history menu, so its accessible
3+
* name has to carry both: speech-input users activate a control by the words they can see, and a
4+
* bare "Chat history" hides them (WCAG 2.5.3). The title leads, because that is what is read.
5+
*/
6+
export function chatHistoryTriggerLabel(title: string): string {
7+
const trimmed = title.trim();
8+
return trimmed ? `${trimmed}, chat history` : "Chat history";
9+
}

apps/webapp/app/components/dashboard-agent/message-limits.test.ts

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { readFileSync } from "node:fs";
12
import { describe, expect, it } from "vitest";
23
import {
34
checkMessageParts,
@@ -6,6 +7,8 @@ import {
67
MAX_MESSAGE_BODY_BYTES,
78
MAX_MESSAGE_CHARS,
89
MAX_MESSAGE_PARTS,
10+
MESSAGE_CHARS_WARN_AT,
11+
messageCountAnnouncement,
912
} from "./message-limits";
1013

1114
describe("message limits", () => {
@@ -61,3 +64,39 @@ describe("message limits", () => {
6164
expect(exceedsMessageBodyBytes(null)).toBe(false);
6265
});
6366
});
67+
68+
describe("messageCountAnnouncement", () => {
69+
it("says nothing at all for a normal message", () => {
70+
expect(messageCountAnnouncement(0)).toBe("");
71+
expect(messageCountAnnouncement(MESSAGE_CHARS_WARN_AT - 1)).toBe("");
72+
});
73+
74+
it("reads the count out from the warning point on", () => {
75+
expect(messageCountAnnouncement(MESSAGE_CHARS_WARN_AT)).toBe(
76+
`${MESSAGE_CHARS_WARN_AT} / ${MAX_MESSAGE_CHARS}`
77+
);
78+
expect(messageCountAnnouncement(MAX_MESSAGE_CHARS)).toBe(
79+
`${MAX_MESSAGE_CHARS} / ${MAX_MESSAGE_CHARS}`
80+
);
81+
});
82+
});
83+
84+
/**
85+
* Structural guard, not behavioural proof: the webapp has no DOM test environment, so nothing
86+
* here mounts the composer or listens to a screen reader. It asserts the live region is written
87+
* unconditionally, which is the part the announcement depends on.
88+
*/
89+
describe("the composer's live region", () => {
90+
const source = readFileSync(new URL("./DashboardAgentComposer.tsx", import.meta.url), "utf8");
91+
92+
it("is in the DOM before the count reaches the warning point", () => {
93+
const region = source.slice(source.indexOf('aria-live="polite"'));
94+
expect(region).toContain("messageCountAnnouncement(value.length)");
95+
// The old form: the region itself only existed past the threshold.
96+
expect(source).not.toMatch(/MESSAGE_CHARS_WARN_AT \? \(\s*<p[^>]*aria-live/);
97+
});
98+
99+
it("does not read the visible counter out a second time", () => {
100+
expect(source).toContain("aria-hidden");
101+
});
102+
});

apps/webapp/app/components/dashboard-agent/message-limits.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,15 @@ export const MAX_MESSAGE_CHARS = 8_000;
1010
/** The counter only shows near the limit, so a normal message never sees it. */
1111
export const MESSAGE_CHARS_WARN_AT = Math.floor(MAX_MESSAGE_CHARS * 0.9);
1212

13+
/**
14+
* What the composer's live region says at this length: empty until the counter is worth
15+
* showing. The region itself stays mounted whatever this returns — several screen readers only
16+
* announce changes to a region that was already in the DOM.
17+
*/
18+
export function messageCountAnnouncement(length: number): string {
19+
return length >= MESSAGE_CHARS_WARN_AT ? `${length} / ${MAX_MESSAGE_CHARS}` : "";
20+
}
21+
1322
/** A composed message is a handful of parts; dozens means something is wrong. */
1423
export const MAX_MESSAGE_PARTS = 20;
1524

0 commit comments

Comments
 (0)