You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Three chat composer controls set ARIA roles and attributes that the code does not support. Keyboard and screen reader users get less than the roles say.
On 14ab7f9c1. Paths are under apps/webapp/src/components/chatroom/components/MessageComposer/.
The insert menu has menu roles but no arrow keys.
The panel sets role="menu", and each row sets role="menuitem" (components/Actions/ActionButtons/ComposerInsertMenu.tsx:135, :47, :56, :70). The + button sets aria-haspopup="menu" (:126).
The shared popover wires click, dismiss, and role only (apps/webapp/src/components/ui/Popover.tsx:64-70). It has no list navigation. So ArrowDown, ArrowUp, Home, and End do nothing in the insert menu.
Each row is a native button (apps/webapp/src/components/ui/Popover.tsx:224-240). Tab moves from row to row, and Enter runs a row. So a keyboard user can reach every row, but not with the arrow keys that a menu must support.
The format row puts aria-pressed on a menuitem (ComposerInsertMenu.tsx:58). ARIA 1.2 allows aria-pressed on button only. The row label already changes between "Text formatting" and "Hide formatting" (:41).
The mention picker does not tell a screen reader which row is highlighted.
The picker is a popup appended to document.body (helpers/suggestion.ts:73-80). Focus stays in the editor element. The rows take no focus, and a mouse press on a row keeps focus in the editor element (helpers/MentionSuggestionRow.tsx:30-38).
ArrowUp and ArrowDown reach the list through the suggestion plugin, and they move selectedIndex (helpers/suggestion.ts:137-155, helpers/MentionList.tsx:129-145).
The listbox holds aria-activedescendant itself (helpers/MentionSuggestions.tsx:81-87). That attribute works only on the element that has focus, and the listbox never has focus.
Tiptap gives the editor element role="textbox". The composer adds no ARIA attributes to it (hooks/useTiptapEditor.ts:207-235). No chatroom code sets aria-activedescendant or aria-controls on the editor element.
So when the highlight moves, a screen reader reads nothing about the new row. Enter then inserts a mention that the user did not hear.
The link URL field already uses the right pattern. It puts aria-controls and aria-activedescendant on the focused field (apps/webapp/src/components/TipTap/hyperlinkPopovers/components/HyperlinkUrlTextarea.tsx:62-70).
The emoji panel handle takes focus but ignores the keyboard.
The handle is a motion.div with role="button", tabIndex={0}, and aria-label="Resize emoji panel" (components/ComposerEmojiPanel/ComposerEmojiPanel.tsx:105-112).
It has only onPanEnd and onClick. A div does not turn Enter or Space into a click. So a keyboard user can focus the handle, but cannot switch the panel between peek and expanded.
The panel shows only in the mobile layout (apps/webapp/src/components/pages/document/components/chat/ChatContainerMobile.tsx:33). Phones and tablets, iPads included, get that layout (apps/webapp/src/components/pages/document/layouts/DocumentLayouts.tsx:21-26, apps/webapp/src/utils/getDeviceInfo.ts:19). So a tablet with a hardware keyboard reaches this handle.
The mention picker and the format row fail WCAG 2.2 success criterion 4.1.2, Name, Role, Value. The handle fails success criterion 2.1.1, Keyboard. The insert menu passes 2.1.1, because Tab works, but it breaks the WAI-ARIA menu pattern.
Steps to reproduce
Insert menu
On desktop, open a heading chat as a channel member.
Click the + button at the left of the composer. The insert menu opens, and focus moves to its first enabled row.
Press ArrowDown. See focus stay on the same row.
Press Tab. See focus move to the next row.
Mention picker
Turn on a screen reader, such as NVDA with Chrome.
In an empty composer, type @, so the mention picker opens.
Press ArrowDown. See the highlight move, and hear nothing about the new row.
In DevTools, inspect the focused .ProseMirror element. See that it has no aria-activedescendant. The listbox under <body> has one.
Emoji panel handle
In Chrome DevTools, turn on device mode with a phone preset. Reload, so the page shows the mobile layout.
Open a heading chat as a channel member. Tap the emoji button, so the emoji panel opens.
Press Tab until the "Resize emoji panel" handle has focus.
Press Enter, then Space. See the panel keep its height.
Click the handle. See the panel expand.
Acceptance criteria
The insert menu roles match its keys. Either the panel keeps role="menu", and ArrowDown, ArrowUp, Home, and End move focus among the enabled rows. Or the panel, its rows, and the + button carry no menu role and no aria-haspopup="menu". Say which on this issue before the change lands.
Opening the insert menu from the keyboard still puts focus on the first enabled row. Enter and Space still run the focused row, and Escape still closes the menu.
No row has aria-pressed while its role is menuitem. The format row states on or off in one way only: an attribute that its role allows, or its label.
While the mention picker shows rows, the editor element has aria-activedescendant set to the id of the row with aria-selected="true". The value follows the highlight on ArrowDown, ArrowUp, and pointer hover.
While the mention picker is open, the editor element has aria-controls set to the listbox id.
When the list has no rows, the editor element has no aria-activedescendant. When the picker closes, the editor element has neither attribute.
The listbox no longer sets aria-activedescendant. The editor element keeps role="textbox".
Enter, and a click on a row, still insert the mention.
With no mention picker open, Enter and Shift+Enter in the composer act as they do today.
With the handle focused, Enter and Space switch the emoji panel between peek and expanded, as a tap does.
A tap and a drag on the handle work as today.
The chatroom Mention Picker rule names the code that sets the editor element's ARIA attributes. The design-system row for the insert menu names the roles that the code uses.
Agent Brief
Category: bug Summary: Make three composer controls behave as their ARIA roles say, for keyboard and screen reader users.
Current behavior:
The insert menu has menu roles, but the shared popover has no list navigation, so arrow keys do nothing. Its format row puts aria-pressed on a menuitem. The mention listbox holds aria-activedescendant, but focus stays in the editor element, which has no link to the listbox. The emoji panel handle takes focus as a button, but it has only pointer handlers.
Desired behavior:
The insert menu either acts as a menu with arrow keys, or drops its menu roles. The format row states on or off in one valid way. While the mention picker is open, the focused editor element points at the listbox and at the highlighted row. The editor element keeps its textbox role. Enter and Space on the focused handle do what a tap does.
Key interfaces:
ComposerInsertMenu and InsertMenuRows — the panel roles, the + button, and the format row.
usePopover, PopoverContent, and PopoverClose — the shared popover. usePopover wires useClick, useDismiss, and useRole only.
useListNavigation from @floating-ui/react — the house ContextMenu uses it with useRole(context, { role: 'menu' }) and a roving tabIndex.
MentionList — owns selectedIndex and the key API that the suggestion plugin calls.
MentionSuggestions and MentionSuggestionRow — the listbox and its rows.
mentionOptionId() and MENTION_LISTBOX_ID — the row ids and the listbox id.
The mention render object — onStart, onExit, and destroyPopup().
editor.view.dom — the editor element that keeps focus.
The combobox prop of HyperlinkUrlTextarea — the house pattern for a focused field that points at a list.
ComposerEmojiPanel — the handle and handleHandleClick.
useComposerEmojiPanelStore — expand() and collapse().
List navigation for every Popover. The shared popover also holds panels that are not menus.
Other panels with role="menu" and no arrow keys: the pad toolbar ToolbarSelect and the media gallery overflow menu.
A peek or expanded state on the handle, such as aria-expanded.
Notes
Coordinate with #268. It changes how the picker closes on Escape. Clear the editor element's attributes in destroyPopup(), the single teardown path, so both fixes share it (apps/webapp/src/components/chatroom/components/MessageComposer/helpers/suggestion.ts:45-54). Write both attributes directly on editor.view.dom. ProseMirror ignores attribute changes on that element, and it removes only attributes that it set itself. Do not route them through editor.setOptions({ editorProps }). That call replaces the whole editorProps object, and its attributes replace Tiptap's. Tiptap puts role="textbox" in those attributes only when it creates the view. So the editor element loses its textbox role. Set the attributes only when the listbox exists. onStart returns before the listbox mounts when the composer surface marker is missing (apps/webapp/src/components/chatroom/components/MessageComposer/helpers/suggestion.ts:60-66).
Coordinate with #286. It edits the same design-system row (.cursor/docs/design-system.md:645).
Coordinate with #288. It gives the editor element an accessible name, so both issues change attributes on the same element.
Library facts, read from the installed sources:
Floating UI 0.27.20 merges user props after interaction props. So role="menu" reaches the DOM over the dialog default of useRole. FloatingFocusManager focuses the first tabbable element on every open.
Tiptap core 3.31.3 sets role="textbox" on the editor element by default. ARIA 1.2 allows aria-activedescendant on textbox, and aria-controls on any role. A combobox role would say that the field has one line.
In motion 13.2.0, only the tap props turn on the keyboard press. It answers Enter only, never Space, and it sends pointer events, not click. The handle has no tap props, so no key reaches onClick. Do not fix the handle with the tap props.
Patterns already in the repo:
apps/webapp/src/components/ui/ContextMenu.tsx:207-214 sets the menu role, list navigation, and typeahead. Its rows use a roving tabIndex (:116).
apps/webapp/src/components/TipTap/hyperlinkPopovers/components/HyperlinkUrlTextarea.tsx:62-70 and apps/webapp/src/components/TipTap/toolbar/desktop/FilterPanel.tsx:181-183 put aria-activedescendant on the focused field.
The renderer already passes the editor to MentionList in its props (apps/webapp/src/components/chatroom/components/MessageComposer/helpers/suggestion.ts:68-71).
The other role="menu" panels need their own issue. apps/webapp/src/components/TipTap/toolbar/ToolbarSelect.tsx:35 sits on the shared popover. apps/webapp/src/components/chatroom/components/ChatMediaGallery/GalleryOverflowMenu.tsx:258 and :291 handle only Escape (:206-211).
A touch screen reader usually sends a click on a double-tap, so it likely reaches the handle today. This was not checked on a device.
The evidence is a code trace on 14ab7f9c1, confirmed by two verifiers, plus reads of the library sources above. Nothing was run in a browser or with a screen reader. Check the mention picker with NVDA and Chrome, and with VoiceOver and Safari, before and after the fix.
Problem
Three chat composer controls set ARIA roles and attributes that the code does not support. Keyboard and screen reader users get less than the roles say.
On
14ab7f9c1. Paths are underapps/webapp/src/components/chatroom/components/MessageComposer/.The insert menu has menu roles but no arrow keys.
role="menu", and each row setsrole="menuitem"(components/Actions/ActionButtons/ComposerInsertMenu.tsx:135,:47,:56,:70). The + button setsaria-haspopup="menu"(:126).apps/webapp/src/components/ui/Popover.tsx:64-70). It has no list navigation. So ArrowDown, ArrowUp, Home, and End do nothing in the insert menu.apps/webapp/src/components/ui/Popover.tsx:224-240). Tab moves from row to row, and Enter runs a row. So a keyboard user can reach every row, but not with the arrow keys that a menu must support.aria-pressedon amenuitem(ComposerInsertMenu.tsx:58). ARIA 1.2 allowsaria-pressedonbuttononly. The row label already changes between "Text formatting" and "Hide formatting" (:41).The mention picker does not tell a screen reader which row is highlighted.
document.body(helpers/suggestion.ts:73-80). Focus stays in the editor element. The rows take no focus, and a mouse press on a row keeps focus in the editor element (helpers/MentionSuggestionRow.tsx:30-38).selectedIndex(helpers/suggestion.ts:137-155,helpers/MentionList.tsx:129-145).aria-activedescendantitself (helpers/MentionSuggestions.tsx:81-87). That attribute works only on the element that has focus, and the listbox never has focus.role="textbox". The composer adds no ARIA attributes to it (hooks/useTiptapEditor.ts:207-235). No chatroom code setsaria-activedescendantoraria-controlson the editor element.The link URL field already uses the right pattern. It puts
aria-controlsandaria-activedescendanton the focused field (apps/webapp/src/components/TipTap/hyperlinkPopovers/components/HyperlinkUrlTextarea.tsx:62-70).The emoji panel handle takes focus but ignores the keyboard.
motion.divwithrole="button",tabIndex={0}, andaria-label="Resize emoji panel"(components/ComposerEmojiPanel/ComposerEmojiPanel.tsx:105-112).onPanEndandonClick. Adivdoes not turn Enter or Space into a click. So a keyboard user can focus the handle, but cannot switch the panel between peek and expanded.apps/webapp/src/components/pages/document/components/chat/ChatContainerMobile.tsx:33). Phones and tablets, iPads included, get that layout (apps/webapp/src/components/pages/document/layouts/DocumentLayouts.tsx:21-26,apps/webapp/src/utils/getDeviceInfo.ts:19). So a tablet with a hardware keyboard reaches this handle.The mention picker and the format row fail WCAG 2.2 success criterion 4.1.2, Name, Role, Value. The handle fails success criterion 2.1.1, Keyboard. The insert menu passes 2.1.1, because Tab works, but it breaks the WAI-ARIA menu pattern.
Steps to reproduce
Insert menu
Mention picker
@, so the mention picker opens..ProseMirrorelement. See that it has noaria-activedescendant. The listbox under<body>has one.Emoji panel handle
Acceptance criteria
role="menu", and ArrowDown, ArrowUp, Home, and End move focus among the enabled rows. Or the panel, its rows, and the + button carry no menu role and noaria-haspopup="menu". Say which on this issue before the change lands.aria-pressedwhile its role ismenuitem. The format row states on or off in one way only: an attribute that its role allows, or its label.aria-activedescendantset to the id of the row witharia-selected="true". The value follows the highlight on ArrowDown, ArrowUp, and pointer hover.aria-controlsset to the listbox id.aria-activedescendant. When the picker closes, the editor element has neither attribute.aria-activedescendant. The editor element keepsrole="textbox".Agent Brief
Category: bug
Summary: Make three composer controls behave as their ARIA roles say, for keyboard and screen reader users.
Current behavior:
The insert menu has menu roles, but the shared popover has no list navigation, so arrow keys do nothing. Its format row puts
aria-pressedon amenuitem. The mention listbox holdsaria-activedescendant, but focus stays in the editor element, which has no link to the listbox. The emoji panel handle takes focus as a button, but it has only pointer handlers.Desired behavior:
The insert menu either acts as a menu with arrow keys, or drops its menu roles. The format row states on or off in one valid way. While the mention picker is open, the focused editor element points at the listbox and at the highlighted row. The editor element keeps its textbox role. Enter and Space on the focused handle do what a tap does.
Key interfaces:
ComposerInsertMenuandInsertMenuRows— the panel roles, the + button, and the format row.usePopover,PopoverContent, andPopoverClose— the shared popover.usePopoverwiresuseClick,useDismiss, anduseRoleonly.useListNavigationfrom@floating-ui/react— the houseContextMenuuses it withuseRole(context, { role: 'menu' })and a rovingtabIndex.MentionList— ownsselectedIndexand the key API that the suggestion plugin calls.MentionSuggestionsandMentionSuggestionRow— the listbox and its rows.mentionOptionId()andMENTION_LISTBOX_ID— the row ids and the listbox id.onStart,onExit, anddestroyPopup().editor.view.dom— the editor element that keeps focus.comboboxprop ofHyperlinkUrlTextarea— the house pattern for a focused field that points at a list.ComposerEmojiPanel— the handle andhandleHandleClick.useComposerEmojiPanelStore—expand()andcollapse().Out of scope
Popover. The shared popover also holds panels that are not menus.role="menu"and no arrow keys: the pad toolbarToolbarSelectand the media gallery overflow menu.aria-expanded.Notes
Coordinate with #268. It changes how the picker closes on Escape. Clear the editor element's attributes in
destroyPopup(), the single teardown path, so both fixes share it (apps/webapp/src/components/chatroom/components/MessageComposer/helpers/suggestion.ts:45-54). Write both attributes directly oneditor.view.dom. ProseMirror ignores attribute changes on that element, and it removes only attributes that it set itself. Do not route them througheditor.setOptions({ editorProps }). That call replaces the wholeeditorPropsobject, and itsattributesreplace Tiptap's. Tiptap putsrole="textbox"in those attributes only when it creates the view. So the editor element loses its textbox role. Set the attributes only when the listbox exists.onStartreturns before the listbox mounts when the composer surface marker is missing (apps/webapp/src/components/chatroom/components/MessageComposer/helpers/suggestion.ts:60-66).Coordinate with #286. It edits the same design-system row (
.cursor/docs/design-system.md:645).Coordinate with #288. It gives the editor element an accessible name, so both issues change attributes on the same element.
Library facts, read from the installed sources:
role="menu"reaches the DOM over thedialogdefault ofuseRole.FloatingFocusManagerfocuses the first tabbable element on every open.role="textbox"on the editor element by default. ARIA 1.2 allowsaria-activedescendantontextbox, andaria-controlson any role. Acomboboxrole would say that the field has one line.motion13.2.0, only the tap props turn on the keyboard press. It answers Enter only, never Space, and it sends pointer events, notclick. The handle has no tap props, so no key reachesonClick. Do not fix the handle with the tap props.Patterns already in the repo:
apps/webapp/src/components/ui/ContextMenu.tsx:207-214sets the menu role, list navigation, and typeahead. Its rows use a rovingtabIndex(:116).apps/webapp/src/components/TipTap/hyperlinkPopovers/components/HyperlinkUrlTextarea.tsx:62-70andapps/webapp/src/components/TipTap/toolbar/desktop/FilterPanel.tsx:181-183putaria-activedescendanton the focused field.MentionListin its props (apps/webapp/src/components/chatroom/components/MessageComposer/helpers/suggestion.ts:68-71).The other
role="menu"panels need their own issue.apps/webapp/src/components/TipTap/toolbar/ToolbarSelect.tsx:35sits on the shared popover.apps/webapp/src/components/chatroom/components/ChatMediaGallery/GalleryOverflowMenu.tsx:258and:291handle only Escape (:206-211).A touch screen reader usually sends a click on a double-tap, so it likely reaches the handle today. This was not checked on a device.
The evidence is a code trace on
14ab7f9c1, confirmed by two verifiers, plus reads of the library sources above. Nothing was run in a browser or with a screen reader. Check the mention picker with NVDA and Chrome, and with VoiceOver and Safari, before and after the fix.