Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions apps/web/src/components/ComposerPromptEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,15 @@ interface ComposerPromptEditorProps {
onCitationSubmitAndSend?: () => void;
onPaste: React.ClipboardEventHandler<HTMLElement>;
editorRef: React.RefObject<ComposerPromptEditorHandle | null>;
/**
* 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;
}

/**
Expand Down Expand Up @@ -1638,6 +1647,8 @@ function ComposerPromptEditorInner({
onCitationSubmitAndSend,
onPaste,
editorRef,
menuListboxId,
menuActiveOptionId,
}: ComposerPromptEditorProps) {
const [editor] = useLexicalComposerContext();
const onChangeRef = useRef(onChange);
Expand Down Expand Up @@ -1985,6 +1996,9 @@ function ComposerPromptEditorInner({
)}
data-testid="composer-editor"
aria-placeholder={placeholder}
ariaAutoComplete={menuListboxId ? "list" : undefined}
ariaControls={menuActiveOptionId ? menuListboxId : undefined}
ariaActiveDescendant={menuActiveOptionId}
placeholder={<span />}
onKeyDown={(event) => {
if (
Expand Down Expand Up @@ -2083,6 +2097,8 @@ export function ComposerPromptEditor({
onCitationSubmitAndSend,
onPaste,
editorRef,
menuListboxId,
menuActiveOptionId,
}: ComposerPromptEditorProps) {
const initialValueRef = useRef(value);
const initialSkillMetadataRef = useRef(skillMetadataByName(skills));
Expand Down Expand Up @@ -2124,6 +2140,8 @@ export function ComposerPromptEditor({
onPaste={onPaste}
{...(onCitationSubmitAndSend ? { onCitationSubmitAndSend } : {})}
editorRef={editorRef}
menuListboxId={menuListboxId}
menuActiveOptionId={menuActiveOptionId}
{...(onCommandKeyDown ? { onCommandKeyDown } : {})}
{...(onPageScrollKeyDown ? { onPageScrollKeyDown } : {})}
{...(onPageScrollKeyUp ? { onPageScrollKeyUp } : {})}
Expand Down
22 changes: 21 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
type ReactNode,
useCallback,
useEffect,
useId,
useImperativeHandle,
useLayoutEffect,
useMemo,
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -6216,6 +6233,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
<ComposerCommandMenuLayer anchor={composerMenuAnchor}>
<ComposerCommandMenu
items={composerMenuItems}
listboxId={composerMenuListboxId}
resolvedTheme={resolvedTheme}
isLoading={isComposerMenuLoading}
triggerKind={composerTriggerKind}
Expand Down Expand Up @@ -6638,6 +6656,8 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onChange={onPromptChange}
onVisibleSelectionChange={expandComposerForEditorChange}
onCommandKeyDown={onComposerCommandKey}
menuListboxId={composerMenuListboxId}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
menuActiveOptionId={composerMenuActiveOptionDomId}
onPageScrollKeyDown={onPageScrollKeyDown}
onPageScrollKeyUp={onPageScrollKeyUp}
onPageScrollRelease={onPageScrollRelease}
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/components/chat/ComposerCommandMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand All @@ -17,6 +29,7 @@ describe("ComposerCommandMenu", () => {
description: "Switch response model for this thread",
},
]}
listboxId="composer-menu"
resolvedTheme="dark"
isLoading={false}
triggerKind="slash-command"
Expand Down Expand Up @@ -48,6 +61,7 @@ describe("ComposerCommandMenu", () => {
description: "Open and control the in-app browser",
},
]}
listboxId="composer-menu"
resolvedTheme="dark"
isLoading={false}
triggerKind="skill"
Expand Down Expand Up @@ -83,6 +97,7 @@ describe("ComposerCommandMenu", () => {
description: "Find the right skill or workflow",
},
]}
listboxId="composer-menu"
resolvedTheme="dark"
isLoading={false}
triggerKind="slash-command"
Expand Down
35 changes: 33 additions & 2 deletions apps/web/src/components/chat/ComposerCommandMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ComposerTriggerKind, string> = {
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;
Expand Down Expand Up @@ -104,12 +126,17 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
data-composer-command-drawer="true"
>
{props.items.length > 0 ? (
<CommandList className="max-h-72 min-h-0 scroll-pb-6">
<CommandList
id={props.listboxId}
aria-label={props.triggerKind ? LISTBOX_LABEL_BY_TRIGGER[props.triggerKind] : undefined}
className="max-h-72 min-h-0 scroll-pb-6"
>
<CommandGroup>
{props.items.map((item) => (
<ComposerCommandMenuItem
key={item.id}
item={item}
domId={composerCommandOptionDomId(props.listboxId, item.id)}
triggerKind={props.triggerKind}
resolvedTheme={props.resolvedTheme}
isActive={props.activeItemId === item.id}
Expand All @@ -121,7 +148,7 @@ export const ComposerCommandMenu = memo(function ComposerCommandMenu(props: {
</CommandList>
) : (
<div className="px-5 pt-3.5 pb-7">
<p className="text-secondary-label text-xs">
<p role="status" className="text-secondary-label text-xs">
{props.isLoading
? props.triggerKind === "skill"
? "Searching workspace skills..."
Expand All @@ -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;
Expand All @@ -159,7 +187,10 @@ const ComposerCommandMenuItem = memo(function ComposerCommandMenuItem(props: {

return (
<CommandItem
// Item owns `id` in its prop types; the render element carries ours.
render={<div id={props.domId} />}
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",
Expand Down
Loading