Skip to content

Commit 0cf482b

Browse files
akjLeos-Khaiclaude
authored
fix(web): make composer suggestions usable with screen readers (#10154)
Co-authored-by: Khai Shern, Toh <[email protected]> Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
1 parent 1905846 commit 0cf482b

6 files changed

Lines changed: 126 additions & 6 deletions

File tree

‎apps/web/src/components/ComposerPromptEditorTiptap.tsx‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,11 @@ export interface ComposerPromptEditorProps {
121121
skills: ReadonlyArray<ServerProviderSkill>;
122122
disabled: boolean;
123123
placeholder: string;
124+
ariaLabel?: string | undefined;
125+
/** Identifies an editor with suggestions, even while its list is closed. */
126+
suggestionListId?: string | undefined;
127+
/** References the highlighted option only while its list is rendered. */
128+
activeSuggestionId?: string | undefined;
124129
containerClassName?: string;
125130
className?: string;
126131
placeholderClassName?: string;
@@ -592,6 +597,9 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
592597
skills,
593598
disabled,
594599
placeholder,
600+
ariaLabel,
601+
suggestionListId,
602+
activeSuggestionId,
595603
containerClassName,
596604
className,
597605
placeholderClassName,
@@ -752,9 +760,25 @@ function ComposerPromptEditorTiptapInner(props: ComposerPromptEditorProps) {
752760
),
753761
"data-testid": "composer-editor",
754762
"data-composer-rich-text": richText ? "true" : "false",
763+
role: "textbox",
764+
"aria-multiline": "true",
765+
...(ariaLabel ? { "aria-label": ariaLabel } : {}),
766+
...(disabled ? { "aria-readonly": "true" } : {}),
767+
...(!disabled && suggestionListId
768+
? {
769+
"aria-autocomplete": "list",
770+
"aria-haspopup": "listbox",
771+
...(activeSuggestionId
772+
? {
773+
"aria-controls": suggestionListId,
774+
"aria-activedescendant": activeSuggestionId,
775+
}
776+
: {}),
777+
}
778+
: {}),
755779
"aria-placeholder": placeholder,
756780
}),
757-
[className, placeholder, richText],
781+
[activeSuggestionId, ariaLabel, className, disabled, placeholder, richText, suggestionListId],
758782
);
759783

760784
const editor = useEditor(

‎apps/web/src/components/chat/ChatComposer.tsx‎

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import {
5858
type ReactNode,
5959
useCallback,
6060
useEffect,
61+
useId,
6162
useImperativeHandle,
6263
useLayoutEffect,
6364
useMemo,
@@ -245,7 +246,11 @@ import { useEnvironmentQuery } from "~/state/query";
245246
import { useDebouncedValue } from "~/state/queries";
246247
import { ProviderModelPicker } from "./ProviderModelPicker";
247248
import { resolveModelPickerSelectedModel } from "./ModelPickerContent";
248-
import { type ComposerCommandItem, ComposerCommandMenu } from "./ComposerCommandMenu";
249+
import {
250+
type ComposerCommandItem,
251+
ComposerCommandMenu,
252+
composerSuggestionOptionId,
253+
} from "./ComposerCommandMenu";
249254
import { ComposerPendingApprovalActions } from "./ComposerPendingApprovalActions";
250255
import { CompactComposerControlsMenu } from "./CompactComposerControlsMenu";
251256
import { ComposerImageThumbnail } from "./ComposerImageThumbnail";
@@ -2108,6 +2113,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
21082113
resetTrigger: resetComposerTrigger,
21092114
} = useComposerTriggerState(() => detectComposerTrigger(prompt, prompt.length));
21102115
const [composerHighlightedItemId, setComposerHighlightedItemId] = useState<string | null>(null);
2116+
const composerSuggestionId = useId();
2117+
const composerSuggestionListId = `${composerSuggestionId}-${encodeURIComponent(draftId ?? activeThreadId ?? "new")}-suggestions`;
21112118
// Active ArrowUp recall. Cleared on edit and on thread switch.
21122119
const promptHistoryPositionRef = useRef<ComposerPromptHistoryPosition | null>(null);
21132120
const [composerHighlightedSearchKey, setComposerHighlightedSearchKey] = useState<string | null>(
@@ -2502,7 +2509,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25022509

25032510
const composerMenuOpen = Boolean(composerTrigger);
25042511
const composerMenuSearchKey = composerTrigger
2505-
? `${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
2512+
? `${composerSuggestionListId}:${composerTrigger.kind}:${composerTrigger.query.trim().toLowerCase()}`
25062513
: null;
25072514
const activeComposerMenuItem = useMemo(() => {
25082515
const activeItemId = resolveComposerMenuActiveItemId({
@@ -2529,6 +2536,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
25292536
);
25302537

25312538
const isComposerApprovalState = activePendingApproval !== null;
2539+
const composerSuggestionsVisible = composerMenuOpen && !isComposerApprovalState;
2540+
const composerSuggestionListVisible = composerSuggestionsVisible && composerMenuItems.length > 0;
25322541
const activePendingUserInput = pendingUserInputs[0] ?? null;
25332542
const isChoiceOnlyPendingQuestion =
25342543
activePendingProgress?.activeQuestion?.allowCustomAnswer === false;
@@ -3176,6 +3185,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
31763185
// ------------------------------------------------------------------
31773186
useEffect(() => {
31783187
setComposerHighlightedItemId(null);
3188+
setComposerHighlightedSearchKey(null);
31793189
setComposerSubmissionError(null);
31803190
setProviderInputSubmissionError(null);
31813191
setComposerCursor(collapseExpandedComposerCursor(promptRef.current, promptRef.current.length));
@@ -6454,9 +6464,19 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
64546464
</ComposerCommandMenuLayer>
64556465
)}
64566466

6457-
{composerMenuOpen && !isComposerApprovalState && (
6467+
<div role="status" aria-atomic="true" className="sr-only">
6468+
{composerSuggestionsVisible && composerMenuItems.length === 0
6469+
? isComposerMenuLoading
6470+
? composerTriggerKind === "pull-request"
6471+
? "Finding pull request..."
6472+
: "Searching workspace files..."
6473+
: composerMenuEmptyState
6474+
: ""}
6475+
</div>
6476+
{composerSuggestionsVisible && (
64586477
<ComposerCommandMenuLayer anchor={composerMenuAnchor}>
64596478
<ComposerCommandMenu
6479+
listId={composerSuggestionListId}
64606480
items={composerMenuItems}
64616481
resolvedTheme={resolvedTheme}
64626482
isLoading={isComposerMenuLoading}
@@ -6845,6 +6865,16 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
68456865
) : null}
68466866
<ComposerContextActionsContext value={composerContextActions}>
68476867
<ComposerPromptEditor
6868+
ariaLabel="Message"
6869+
suggestionListId={composerSuggestionListId}
6870+
activeSuggestionId={
6871+
composerSuggestionListVisible && activeComposerMenuItem
6872+
? composerSuggestionOptionId(
6873+
composerSuggestionListId,
6874+
activeComposerMenuItem.id,
6875+
)
6876+
: undefined
6877+
}
68486878
editorRef={composerEditorRef}
68496879
richTextEnabled={settings.composerRichTextEnabled}
68506880
value={

‎apps/web/src/components/chat/ComposerCommandMenu.test.tsx‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,37 @@ import { renderToStaticMarkup } from "react-dom/server";
22
import { ProviderDriverKind } from "@t3tools/contracts";
33
import { describe, expect, it } from "vite-plus/test";
44

5-
import { ComposerCommandMenu } from "./ComposerCommandMenu";
5+
import { ComposerCommandMenu, composerSuggestionOptionId } from "./ComposerCommandMenu";
6+
7+
describe("composerSuggestionOptionId", () => {
8+
it("keeps whitespace, escape-like paths, and malformed UTF-16 distinct", () => {
9+
const paths = [
10+
"docs/my file.md",
11+
"docs/my_file.md",
12+
"docs/my%20file.md",
13+
"docs/my\tfile.md",
14+
"docs/\ud800.md",
15+
"docs/\ud801.md",
16+
"docs/\udc00.md",
17+
"docs/\ufffd.md",
18+
"docs/\\ud800.md",
19+
"docs/\ud83d\ude80.md",
20+
];
21+
const ids = paths.map((path) => composerSuggestionOptionId("suggestions", `path:file:${path}`));
22+
23+
expect(new Set(ids).size).toBe(paths.length);
24+
for (const id of ids) expect(id).not.toMatch(/\s|[\ud800-\udfff]/u);
25+
expect(composerSuggestionOptionId("other-composer", paths[0]!)).not.toBe(
26+
composerSuggestionOptionId("suggestions", paths[0]!),
27+
);
28+
});
29+
});
630

731
describe("ComposerCommandMenu", () => {
832
it("renders slash commands with their descriptions", () => {
933
const markup = renderToStaticMarkup(
1034
<ComposerCommandMenu
35+
listId="test-suggestions"
1136
items={[
1237
{
1338
id: "slash:model",
@@ -33,6 +58,7 @@ describe("ComposerCommandMenu", () => {
3358
it("shows the app source for an app skill", () => {
3459
const markup = renderToStaticMarkup(
3560
<ComposerCommandMenu
61+
listId="test-suggestions"
3662
items={[
3763
{
3864
id: "skill:codex:browser",
@@ -67,6 +93,7 @@ describe("ComposerCommandMenu", () => {
6793
it("shows the repo source for a slash skill", () => {
6894
const markup = renderToStaticMarkup(
6995
<ComposerCommandMenu
96+
listId="test-suggestions"
7097
items={[
7198
{
7299
id: "skill:codex:ask-matt",

‎apps/web/src/components/chat/ComposerCommandMenu.tsx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export type ComposerCommandItem =
6969
};
7070

7171
export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
72+
listId: string;
7273
items: ComposerCommandItem[];
7374
resolvedTheme: "light" | "dark";
7475
isLoading: boolean;
@@ -104,11 +105,16 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
104105
data-composer-command-drawer="true"
105106
>
106107
{props.items.length > 0 ? (
107-
<CommandList className="max-h-72 min-h-0 scroll-pb-6">
108+
<CommandList
109+
id={props.listId}
110+
aria-label={props.triggerKind ? LISTBOX_LABEL_BY_TRIGGER[props.triggerKind] : undefined}
111+
className="max-h-72 min-h-0 scroll-pb-6"
112+
>
108113
<CommandGroup>
109114
{props.items.map((item) => (
110115
<ComposerCommandMenuItem
111116
key={item.id}
117+
optionId={composerSuggestionOptionId(props.listId, item.id)}
112118
item={item}
113119
triggerKind={props.triggerKind}
114120
resolvedTheme={props.resolvedTheme}
@@ -143,6 +149,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
143149
});
144150

145151
const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
152+
optionId: string;
146153
item: ComposerCommandItem;
147154
triggerKind: ComposerTriggerKind | null;
148155
resolvedTheme: "light" | "dark";
@@ -159,6 +166,8 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
159166

160167
return (
161168
<CommandItem
169+
render={<div id={props.optionId} />}
170+
aria-selected={props.isActive}
162171
value={props.item.id}
163172
data-composer-item-id={props.item.id}
164173
active={props.isActive}
@@ -211,6 +220,18 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {
211220
);
212221
});
213222

223+
export function composerSuggestionOptionId(listId: string, itemId: string): string {
224+
// JSON escapes lone UTF-16 surrogates before URI encoding without losing identity.
225+
return `${listId}-${encodeURIComponent(JSON.stringify(itemId))}`;
226+
}
227+
228+
const LISTBOX_LABEL_BY_TRIGGER: Record<ComposerTriggerKind, string> = {
229+
path: "Files and folders",
230+
"pull-request": "Pull requests",
231+
"slash-command": "Commands",
232+
skill: "Skills",
233+
};
234+
214235
const SKILL_SOURCE_ICON_BY_KIND: Record<ProviderSkillSourceKind, LucideIcon> = {
215236
app: BlocksIcon,
216237
repo: FolderIcon,

‎apps/web/src/components/chat/composerMenuHighlight.test.ts‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,4 +48,21 @@ describe("resolveComposerMenuActiveItemId", () => {
4848
}),
4949
).toBe("top");
5050
});
51+
52+
it("clears the active result while async results are empty and resolves against restored results", () => {
53+
const search = {
54+
highlightedItemId: "second",
55+
currentSearchKey: "path:src",
56+
highlightedSearchKey: "path:src",
57+
};
58+
const cleared = resolveComposerMenuActiveItemId({ ...search, items: [] });
59+
expect(cleared).toBeNull();
60+
expect(
61+
resolveComposerMenuActiveItemId({
62+
...search,
63+
highlightedItemId: cleared,
64+
items: [{ id: "new-result" }, { id: "second" }],
65+
}),
66+
).toBe("new-result");
67+
});
5168
});

‎apps/web/src/components/settings/SettingsFontPreviews.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export function PromptFontPreview() {
3939
return (
4040
<div className="mt-1 mb-2 rounded-lg border border-border bg-background px-3 py-2">
4141
<ComposerPromptEditor
42+
ariaLabel="Prompt font preview"
4243
editorRef={editorRef}
4344
value={prompt}
4445
cursor={cursor}

0 commit comments

Comments
 (0)