diff --git a/apps/web/src/components/ComposerPromptEditor.tsx b/apps/web/src/components/ComposerPromptEditor.tsx index 3ba086e0e981..8cd441db8ae6 100644 --- a/apps/web/src/components/ComposerPromptEditor.tsx +++ b/apps/web/src/components/ComposerPromptEditor.tsx @@ -892,6 +892,15 @@ interface ComposerPromptEditorProps { onCitationSubmitAndSend?: () => void; onPaste: React.ClipboardEventHandler; editorRef: React.RefObject; + /** + * 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; } /** @@ -1638,6 +1647,8 @@ function ComposerPromptEditorInner({ onCitationSubmitAndSend, onPaste, editorRef, + menuListboxId, + menuActiveOptionId, }: ComposerPromptEditorProps) { const [editor] = useLexicalComposerContext(); const onChangeRef = useRef(onChange); @@ -1985,6 +1996,9 @@ function ComposerPromptEditorInner({ )} data-testid="composer-editor" aria-placeholder={placeholder} + ariaAutoComplete={menuListboxId ? "list" : undefined} + ariaControls={menuActiveOptionId ? menuListboxId : undefined} + ariaActiveDescendant={menuActiveOptionId} placeholder={} onKeyDown={(event) => { if ( @@ -2083,6 +2097,8 @@ export function ComposerPromptEditor({ onCitationSubmitAndSend, onPaste, editorRef, + menuListboxId, + menuActiveOptionId, }: ComposerPromptEditorProps) { const initialValueRef = useRef(value); const initialSkillMetadataRef = useRef(skillMetadataByName(skills)); @@ -2124,6 +2140,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..4a8b0f38f7a7 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,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ); const isComposerApprovalState = activePendingApproval !== null; + + // 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 composerMenuActiveOptionDomId = + composerMenuOpen && + !isComposerApprovalState && + composerMenuItems.length > 0 && + activeComposerMenuItem + ? composerCommandOptionDomId(composerMenuListboxId, activeComposerMenuItem.id) + : undefined; + const activePendingUserInput = pendingUserInputs[0] ?? null; const isChoiceOnlyPendingQuestion = activePendingProgress?.activeQuestion?.allowCustomAnswer === false; @@ -6216,6 +6233,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) { + 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", () => { @@ -17,6 +29,7 @@ describe("ComposerCommandMenu", () => { description: "Switch response model for this thread", }, ]} + listboxId="composer-menu" resolvedTheme="dark" isLoading={false} triggerKind="slash-command" @@ -48,6 +61,7 @@ describe("ComposerCommandMenu", () => { description: "Open and control the in-app browser", }, ]} + listboxId="composer-menu" resolvedTheme="dark" isLoading={false} triggerKind="skill" @@ -83,6 +97,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..bfc5c583762d 100644 --- a/apps/web/src/components/chat/ComposerCommandMenu.tsx +++ b/apps/web/src/components/chat/ComposerCommandMenu.tsx @@ -68,8 +68,30 @@ 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; + * the fixed-width escape keeps ids that differ only there distinct. + */ +export function composerCommandOptionDomId(listboxId: string, itemId: string): string { + const escaped = itemId.replace( + /[\s%]/g, + (char) => `%${char.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); + return `${listboxId}-${escaped}`; +} + +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 +126,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 +171,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 +187,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",