diff --git a/dashboard/src/components/ChatPicker/SearchablePickerPanel.a11y.test.tsx b/dashboard/src/components/ChatPicker/SearchablePickerPanel.a11y.test.tsx new file mode 100644 index 0000000000..6023c26e79 --- /dev/null +++ b/dashboard/src/components/ChatPicker/SearchablePickerPanel.a11y.test.tsx @@ -0,0 +1,117 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; + +/** + * A11Y-11 regression guard (shared picker panel half). + * + * SearchablePickerPanel backs five of the six chat pickers (expert, knowledge, + * skill, subagent, connector), which own no list markup themselves. Before this + * fix it had zero ARIA: the search box was named only by a placeholder, and + * filtering was completely silent. + * + * These panels are MULTI-SELECT TOGGLES inside a popover, not comboboxes. That + * is why the list is role="group" with aria-pressed on the items rather than + * role="listbox" with aria-selected: the rows contain real + )} + footerIcon={null} + footerLabel="Manage" + onFooterClick={vi.fn()} + {...overrides} + />, + ); +} + +describe("searchable picker panel (A11Y-11)", () => { + it("gives the search field an accessible name, not just a placeholder", () => { + setup(); + // The placeholder disappears on first keystroke and is not a name. + expect(screen.getByRole("searchbox")).toHaveAccessibleName("Things"); + }); + + it("exposes the list as a labelled group", () => { + setup(); + expect(screen.getByRole("group")).toHaveAccessibleName("Things"); + }); + + it("announces the result count through a live region", () => { + // Filtering was previously silent: the list could shrink to nothing with no + // announcement at all. + setup(); + const status = screen.getByRole("status"); + expect(status).toHaveAttribute("aria-live", "polite"); + expect(status).toHaveTextContent("2 results"); + }); + + it("keeps the live region present even when the filter matches nothing", () => { + // An aria-live region must exist BEFORE its content changes, or the update + // is not announced. Rendering it only in the non-empty branch would break + // exactly the case that needs announcing most. + setup({ items: [] }); + expect(screen.getByRole("status")).toHaveTextContent("0 results"); + }); + + it("still renders the items and the empty state", () => { + setup(); + expect(screen.getAllByRole("button")).toHaveLength(3); // 2 rows + footer + expect(screen.getByText("Alpha")).toBeInTheDocument(); + + setup({ items: [] }); + expect(screen.getByText("Nothing here")).toBeInTheDocument(); + }); + + it("carries aria-pressed through from renderItem", () => { + // The panel does not own toggle state - callers pass it in renderItem - so + // this pins that the panel does not strip it. + setup(); + expect(screen.getByRole("button", { name: "Alpha" })).toHaveAttribute( + "aria-pressed", + "true", + ); + expect(screen.getByRole("button", { name: "Beta" })).toHaveAttribute( + "aria-pressed", + "false", + ); + }); +}); diff --git a/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx b/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx index bbbe62d7ea..b09a85b8c7 100644 --- a/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx +++ b/dashboard/src/components/ChatPicker/SearchablePickerPanel.tsx @@ -1,4 +1,5 @@ import type { ReactNode } from "react"; +import { useTranslation } from "react-i18next"; import { Search } from "lucide-react"; import { useFilteredList } from "../../hooks/useFilteredList"; import styles from "./picker.module.less"; @@ -22,6 +23,13 @@ interface SearchablePickerPanelProps { footerMuted?: boolean; /** Optional row(s) rendered between the list and the primary footer. */ beforeFooter?: ReactNode; + /** + * Names the search field and the option group. These panels are multi-select + * toggles inside a popover, so their items are buttons with aria-pressed + * rather than listbox options — the label is what tells a screen reader what + * the set of toggles controls. + */ + ariaLabel: string; } export default function SearchablePickerPanel({ @@ -38,7 +46,9 @@ export default function SearchablePickerPanel({ onFooterClick, footerMuted = false, beforeFooter, + ariaLabel, }: SearchablePickerPanelProps) { + const { t } = useTranslation(); const { query, setQuery, filtered } = useFilteredList(items, filterFn); const panelClass = width === "compact" @@ -53,6 +63,9 @@ export default function SearchablePickerPanel({ setQuery(e.target.value)} @@ -60,7 +73,25 @@ export default function SearchablePickerPanel({ -
+ {/* Filtering is visually obvious and was completely silent to assistive + tech: the list can shrink to empty with no announcement. */} +
+ {t("chat.pickerResultCount", "{{count}} results", { + count: filtered.length, + })} +
+ +
{filtered.length === 0 ? (
{emptyMessage}
) : ( diff --git a/dashboard/src/components/ChatPicker/picker.module.less b/dashboard/src/components/ChatPicker/picker.module.less index 56d06fddb4..02ec29a2b0 100644 --- a/dashboard/src/components/ChatPicker/picker.module.less +++ b/dashboard/src/components/ChatPicker/picker.module.less @@ -51,6 +51,20 @@ pointer-events: none; } +/* Visually hidden: the result count is announced, not shown. Filtering already + has a visible signal (the list shrinks), so this must not alter layout. */ +.searchStatus { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + padding: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + .list { max-height: 320px; overflow-y: auto; 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(