From eb14878d8cf8642456af98c38217abc4a823d2d3 Mon Sep 17 00:00:00 2001 From: "Khai Shern, Toh" Date: Mon, 14 Sep 2026 08:28:23 +0800 Subject: [PATCH 1/2] fix(web): announce composer trigger menus to screen readers The @, $, /, and # popups already followed Up/Down, Enter, and Tab from the editor, but the editor never exposed the list, so screen readers heard nothing when it opened or when the highlight moved. Wire the ARIA combobox pattern between the Lexical editor and the menu: the listbox gets a stable id and a per-trigger label, options get deterministic ids and aria-selected, and the editor points at them via aria-controls and aria-activedescendant while the list is rendered. The empty and loading text is a status region. Focus stays in the editor and nothing changes visually. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/components/ComposerPromptEditor.tsx | 16 ++++++++++ apps/web/src/components/chat/ChatComposer.tsx | 21 ++++++++++++- .../chat/ComposerCommandMenu.test.tsx | 3 ++ .../components/chat/ComposerCommandMenu.tsx | 30 +++++++++++++++++-- 4 files changed, 67 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 3ba086e0e981..a065b4e0baaa 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -892,6 +892,13 @@ interface ComposerPromptEditorProps { onCitationSubmitAndSend?: () => void; onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; + /** + * Listbox of the open trigger menu and its highlighted option. Wiring them + * into aria-controls / aria-activedescendant lets screen readers follow the + * menu while focus never leaves the editor. + */ + menuListboxId?: string | undefined; + menuActiveOptionId?: string | undefined; } /** @@ -1638,6 +1645,8 @@ function ComposerPromptEditorInner({ onCitationSubmitAndSend, onPaste, editorRef, + menuListboxId, + menuActiveOptionId, }: ComposerPromptEditorProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); @@ -1985,6 +1994,9 @@ function ComposerPromptEditorInner({ )} data-testid="composer-editor" aria-placeholder={placeholder} + ariaAutoComplete="list" + ariaControls={menuListboxId} + ariaActiveDescendant={menuActiveOptionId} placeholder={} onKeyDown={(event) => { if ( @@ -2083,6 +2095,8 @@ export function ComposerPromptEditor({ onCitationSubmitAndSend, onPaste, editorRef, + menuListboxId, + menuActiveOptionId, }: ComposerPromptEditorProps) { const initialValueRef = useRef(value); const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); @@ -2124,6 +2138,8 @@ export function ComposerPromptEditor({ onPaste={onPaste} {...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})} editorRef={editorRef} + menuListboxId={menuListboxId} + menuActiveOptionId={menuActiveOptionId} {...(onCommandKeyDown ? { onCommandKeyDown } : {})} {...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})} {...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e3ae13911036..40083be4a4ee 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -55,6 +55,7 @@ import { type ReactNode, useCallback, useEffect, + useId, useImperativeHandle, useLayoutEffect, useMemo, @@ -239,7 +240,11 @@ import { import { useEnvironmentQuery } from "~/state/query"; import { useDebouncedValue } from "~/state/queries"; import { ProviderModelPicker } from "./ProviderModelPicker"; -import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu"; +import { + type ComposerCommandItem, + ComposerCommandMenu, + composerCommandOptionDomId, +} from "./ComposerCommandMenu"; import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions"; import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu"; import { ComposerPrimaryActions } from "./ComposerPrimaryActions"; @@ -2461,6 +2466,17 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const isComposerApprovalState = activePendingApproval !== null; + + // The editor only references the listbox while it is actually rendered, so + // aria-controls and aria-activedescendant never point at missing nodes. + const composerMenuListboxId = useId(); + const composerMenuListboxRendered = + composerMenuOpen && !isComposerApprovalState && composerMenuItems.length > 0; + const composerMenuActiveOptionDomId = + composerMenuListboxRendered && activeComposerMenuItem + ? composerCommandOptionDomId(composerMenuListboxId, activeComposerMenuItem.id) + : undefined; + const activePendingUserInput = pendingUserInputs[0] ?? null; const isChoiceOnlyPendingQuestion = activePendingProgress?.activeQuestion?.allowCustomAnswer === false; @@ -6216,6 +6232,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { description: "Switch response model for this thread", }, ]} + listboxId="composer-menu" resolvedTheme="dark" isLoading={false} triggerKind="slash-command" @@ -48,6 +49,7 @@ describe("ComposerCommandMenu", () => { description: "Open and control the in-app browser", }, ]} + listboxId="composer-menu" resolvedTheme="dark" isLoading={false} triggerKind="skill" @@ -83,6 +85,7 @@ describe("ComposerCommandMenu", () => { description: "Find the right skill or workflow", }, ]} + listboxId="composer-menu" resolvedTheme="dark" isLoading={false} triggerKind="slash-command" diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 9cdfc37a329f..0ff7a7c66af6 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -68,8 +68,25 @@ export type ComposerCommandItem = description: string; }; +/** + * DOM id of a menu option, so the editor's aria-activedescendant can point at + * it. Item ids embed paths and may contain whitespace, which an IDREF cannot. + */ +export function composerCommandOptionDomId(listboxId: string, itemId: string): string { + return `${listboxId}-${itemId.replace(/\s/g, "_")}`; +} + +const LISTBOX_LABEL_BY_TRIGGER: Record = { + path: "Files and folders", + "pull-request": "Pull requests", + "slash-command": "Commands", + skill: "Skills", +}; + export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { items: ComposerCommandItem[]; + /** Id the editor references via aria-controls while the menu is open. */ + listboxId: string; resolvedTheme: "light" | "dark"; isLoading: boolean; triggerKind: ComposerTriggerKind | null; @@ -104,12 +121,17 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { data-composer-command-drawer="true" > {props.items.length > 0 ? ( - + {props.items.map((item) => ( ) : (
-

+

{props.isLoading ? props.triggerKind === "skill" ? "Searching workspace skills..." @@ -144,6 +166,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: { const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { item: ComposerCommandItem; + domId: string; triggerKind: ComposerTriggerKind | null; resolvedTheme: "light" | "dark"; isActive: boolean; @@ -159,7 +182,10 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: { return ( } value={props.item.id} + aria-selected={props.isActive} data-composer-item-id={props.item.id} className={cn( "cursor-pointer select-none gap-3 rounded-lg px-3 py-2! hover:bg-transparent hover:text-inherit data-highlighted:bg-transparent data-highlighted:text-inherit", From 1c7a233fadc19e698671c702461848edb29ca0fa Mon Sep 17 00:00:00 2001 From: "Khai Shern, Toh" Date: Mon, 14 Sep 2026 08:56:45 +0800 Subject: [PATCH 2/2] fix(web): keep composer menu option ids unique and autocomplete honest Advertise list autocomplete only when the editor is wired to a trigger menu, so the Settings font preview stops claiming one. Escape whitespace in option ids reversibly so paths that differ only by a space no longer share a DOM id, and cover the helper with a unit test. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/ComposerPromptEditor.tsx | 12 +++++++----- apps/web/src/components/chat/ChatComposer.tsx | 13 +++++++------ .../components/chat/ComposerCommandMenu.test.tsx | 14 +++++++++++++- .../src/components/chat/ComposerCommandMenu.tsx | 9 +++++++-- 4 files changed, 34 insertions(+), 14 deletions(-) diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index a065b4e0baaa..8cd441db8ae6 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -893,9 +893,11 @@ interface ComposerPromptEditorProps { onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; /** - * Listbox of the open trigger menu and its highlighted option. Wiring them - * into aria-controls / aria-activedescendant lets screen readers follow the - * menu while focus never leaves the editor. + * Trigger-menu wiring for screen readers. `menuListboxId` is the listbox the + * menus render into and, when present, advertises list autocomplete. + * `menuActiveOptionId` is the highlighted option while that listbox is + * rendered; it drives aria-activedescendant so the reader follows the menu + * without focus ever leaving the editor. */ menuListboxId?: string | undefined; menuActiveOptionId?: string | undefined; @@ -1994,8 +1996,8 @@ function ComposerPromptEditorInner({ )} data-testid="composer-editor" aria-placeholder={placeholder} - ariaAutoComplete="list" - ariaControls={menuListboxId} + ariaAutoComplete={menuListboxId ? "list" : undefined} + ariaControls={menuActiveOptionId ? menuListboxId : undefined} ariaActiveDescendant={menuActiveOptionId} placeholder={} onKeyDown={(event) => { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 40083be4a4ee..4a8b0f38f7a7 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -2467,13 +2467,14 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) const isComposerApprovalState = activePendingApproval !== null; - // The editor only references the listbox while it is actually rendered, so - // aria-controls and aria-activedescendant never point at missing nodes. + // The editor only references an option while the listbox is actually + // rendered, so aria-activedescendant never points at a missing node. const composerMenuListboxId = useId(); - const composerMenuListboxRendered = - composerMenuOpen && !isComposerApprovalState && composerMenuItems.length > 0; const composerMenuActiveOptionDomId = - composerMenuListboxRendered && activeComposerMenuItem + composerMenuOpen && + !isComposerApprovalState && + composerMenuItems.length > 0 && + activeComposerMenuItem ? composerCommandOptionDomId(composerMenuListboxId, activeComposerMenuItem.id) : undefined; @@ -6655,7 +6656,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onChange={onPromptChange} onVisibleSelectionChange={expandComposerForEditorChange} onCommandKeyDown={onComposerCommandKey} - menuListboxId={composerMenuListboxRendered ? composerMenuListboxId : undefined} + menuListboxId={composerMenuListboxId} menuActiveOptionId={composerMenuActiveOptionDomId} onPageScrollKeyDown={onPageScrollKeyDown} onPageScrollKeyUp={onPageScrollKeyUp} diff --git a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx index 825eebaf86db..844c3949c32f 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.test.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.test.tsx @@ -2,7 +2,19 @@ import { renderToStaticMarkup } from "react-dom/server"; import { ProviderDriverKind } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { ComposerCommandMenu } from "./ComposerCommandMenu"; +import { ComposerCommandMenu, composerCommandOptionDomId } from "./ComposerCommandMenu"; + +describe("composerCommandOptionDomId", () => { + it("produces a whitespace-free id that stays distinct across near-identical paths", () => { + const spaced = composerCommandOptionDomId("menu", "path:file:docs/my file.md"); + const underscored = composerCommandOptionDomId("menu", "path:file:docs/my_file.md"); + const escaped = composerCommandOptionDomId("menu", "path:file:docs/my%0020file.md"); + + expect(spaced).not.toMatch(/\s/); + expect(new Set([spaced, underscored, escaped]).size).toBe(3); + expect(composerCommandOptionDomId("menu", "slash:model")).toBe("menu-slash:model"); + }); +}); describe("ComposerCommandMenu", () => { it("renders slash commands with their descriptions", () => { diff --git a/apps/web/src/components/chat/ComposerCommandMenu.tsx b/apps/web/src/components/chat/ComposerCommandMenu.tsx index 0ff7a7c66af6..bfc5c583762d 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -70,10 +70,15 @@ export type ComposerCommandItem = /** * DOM id of a menu option, so the editor's aria-activedescendant can point at - * it. Item ids embed paths and may contain whitespace, which an IDREF cannot. + * it. Item ids embed paths and may contain whitespace, which an IDREF cannot; + * the fixed-width escape keeps ids that differ only there distinct. */ export function composerCommandOptionDomId(listboxId: string, itemId: string): string { - return `${listboxId}-${itemId.replace(/\s/g, "_")}`; + const escaped = itemId.replace( + /[\s%]/g, + (char) => `%${char.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + return `${listboxId}-${escaped}`; } const LISTBOX_LABEL_BY_TRIGGER: Record = {