From 28cd6622234e9913e875f74ce7e9fcad19239513 Mon Sep 17 00:00:00 2001
From: pt-act <211776491+pt-act@users.noreply.github.com>
Date: Mon, 19 Oct 2026 12:59:38 +0100
Subject: [PATCH 1/3] feat(dashboard): give the mention picker real
listbox/combobox semantics (A11Y-11)
Verified live first: 0 listbox/combobox/option roles existed anywhere under
pages/Chat. The menu was a stack of divs and buttons, so the highlight that the
arrow keys move was invisible to assistive tech - a screen reader got the text of
every suggestion with no indication which one Enter would pick.
The menu is now role="listbox" with role="option" children and aria-selected on
the active one, and the composer textarea is role="combobox" with
aria-activedescendant pointing at the active option's id. Focus stays in the
textarea and the highlighted item is announced, which is the ARIA 1.2 composite
pattern for a text input with a popup listbox.
Ids come from mentionOptionId(index) so they stay stable across re-renders and
solvable by getElementById - an aria-activedescendant that resolves to nothing is
inert. The empty state is a listbox too, because aria-controls points at
MENTION_LISTBOX_ID whenever the menu is open and a non-listbox target would break
the relationship.
Two things this surfaced:
1. role="combobox" on the textarea is a role CHANGE - a combobox is a composite
widget, not a textbox, so getByRole("textbox") stops matching. That is correct
per spec, and the four queries in ChatInput.prefill.test.tsx are updated
rather than the role being weakened. A dynamic role (combobox only while
expanded) was rejected: ARIA warns against roles that change, because AT
caches them.
2. A combobox must have an accessible name, and it had none - only a
placeholder, which disappears on first keystroke and is ignored by some AT.
aria-label added.
Scope note: this covers the mention picker only. The six sibling picker popovers
(Expert, Knowledge, Skill, Subagent, Connector, HITL) still need the same
treatment; they do not all share this menu component.
Verified: tsc -b clean, eslint 0 errors (3 pre-existing exhaustive-deps warnings
in ChatInput.tsx, untouched), 7 new cases in MentionPickerMenu.a11y.test.tsx and
14 passing across the chat suites. Mutation-verified - removing role/aria-selected
fails 5 cases, and giving every option the same id fails exactly the one
id-resolution case.
---
.../components/ChatInput.prefill.test.tsx | 8 +-
.../src/pages/Chat/components/ChatInput.tsx | 20 ++-
.../MentionPickerMenu.a11y.test.tsx | 128 ++++++++++++++++++
.../Chat/components/MentionPickerMenu.tsx | 37 ++++-
4 files changed, 185 insertions(+), 8 deletions(-)
create mode 100644 dashboard/src/pages/Chat/components/MentionPickerMenu.a11y.test.tsx
diff --git a/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx b/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx
index 13f64f4d2f..2d3582f477 100644
--- a/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx
+++ b/dashboard/src/pages/Chat/components/ChatInput.prefill.test.tsx
@@ -162,7 +162,7 @@ describe("ChatInput prefill clear-on-send", () => {
/>,
);
- const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ const textarea = screen.getByRole("combobox") as HTMLTextAreaElement;
const prefill = "请填写收件人:";
act(() => {
@@ -214,7 +214,7 @@ describe("ChatInput prefill clear-on-send", () => {
/>,
);
- const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ const textarea = screen.getByRole("combobox") as HTMLTextAreaElement;
const prefill = "请填写收件人:";
parentPrefill = prefill;
@@ -260,7 +260,7 @@ describe("ChatInput prefill clear-on-send", () => {
};
const { rerender } = render();
- const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ const textarea = screen.getByRole("combobox") as HTMLTextAreaElement;
expect(textarea.value).toBe("previous expert prompt");
rerender();
@@ -283,7 +283,7 @@ describe("ChatInput prefill clear-on-send", () => {
threadId="thread-1"
/>,
);
- const textarea = screen.getByRole("textbox") as HTMLTextAreaElement;
+ const textarea = screen.getByRole("combobox") as HTMLTextAreaElement;
fireEvent.change(textarea, { target: { value: "second question" } });
fireEvent.click(screen.getByRole("button", { name: "send" }));
expect(onSend).toHaveBeenCalledWith("second question", undefined);
diff --git a/dashboard/src/pages/Chat/components/ChatInput.tsx b/dashboard/src/pages/Chat/components/ChatInput.tsx
index 257688728e..13fa55f74d 100644
--- a/dashboard/src/pages/Chat/components/ChatInput.tsx
+++ b/dashboard/src/pages/Chat/components/ChatInput.tsx
@@ -25,7 +25,10 @@ import type { KnowledgeBase } from "../../../api/modules/knowledgeBases";
import type { SkillSpec } from "../../Agent/Skills/useSkills";
import type { ChatAgentOption } from "./ExpertAgentAvatar";
import type { AgentSubagentSummary } from "../../../api/modules/subagents";
-import MentionPickerMenu from "./MentionPickerMenu";
+import MentionPickerMenu, {
+ MENTION_LISTBOX_ID,
+ mentionOptionId,
+} from "./MentionPickerMenu";
import ChatInputPreviewBar from "./ChatInputPreviewBar";
import ChatInputActionsRow from "./ChatInputActionsRow";
import ChatQueuedMessages from "./ChatQueuedMessages";
@@ -801,6 +804,21 @@ const ChatInput = forwardRef(