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(