Skip to content

Announce an empty Slash menu result, and give each option a stable id #333

Description

@HMarzban

Parent

#328. Related: #251

What to build

A screen-reader user types /tabel, and the Slash menu empties in silence. Command jump and the chat Mention Picker have a status; the Slash menu has none. Option ids also come from the row position, so a new first row after a filter keeps the old id and may go unread.

After the fix, "No matching blocks" is spoken once, and each option id names its row. The design system also gains a "Slash menu popup" entry that records the picker status recipe for other pickers.

Acceptance criteria

  • Typing /zzz shows "No matching blocks" in one node. That node is also the polite status region.
  • That node sits outside role="listbox" and outside .ProseMirror, in the desktop popup and in the phone sheet.
  • SlashMenuList renders that node, empty, while the session is null. So the node is in the DOM before the list gets its first result.
  • While rows show, that node is empty and adds no height.
  • The status text is only ever empty or "No matching blocks". It never holds a count.
  • Option ids come from item.id. Type /h, press Backspace once, then type b: the editor's aria-activedescendant changes value.
  • data-testid="slash-menu" stays on the listbox only. The listbox still renders only while a session exists.
  • Enter with no match still makes a new line.
  • .cursor/docs/design-system.md §Floating overlays has a "Slash menu popup" entry with the picker status recipe.
  • Maintainer check, after the build: a screen-reader walk passes, and its result is posted here as a comment. The agent does not tick this box.

Blocked by

None — can start now.

Agent brief

Type: AFK — an agent can finish this alone. The last acceptance box is a maintainer screen-reader walk after the build.

Category: bug

Current behavior:
All slash files live in apps/webapp/src/components/TipTap/slash/.

  • "No matching blocks" is a plain div inside role="listbox", with no live region (SlashMenuList.tsx:48-49).
  • SlashMenuList returns null while there is no session (SlashMenuList.tsx:38). The session arrives later, in onUpdate, after items() resolves (renderSlashMenu.ts:35-46, :81).
  • Option ids are slash-menu-option-${index} (slashMenuSession.ts:6, used at SlashMenuList.tsx:23 and :57).
  • Both hosts render SlashMenuList. The desktop popup mounts through props.mount, which appends it to document.body, outside the editor (renderSlashMenu.ts:69-78). The phone sheet is SlashMenuSheet.tsx, opened with trapFocus: false (apps/webapp/src/components/BottomSheet.tsx, slashMenu entry).

Desired behavior:

  • SlashMenuList returns a fragment: the listbox, plus one status node as its sibling.
  • Render the status node even while the session is null. It then mounts empty in both hosts, before any text arrives. Screen readers can skip text that is present when a live region first mounts (unverified here; the maintainer walk checks it).
  • The status node is role="status" with aria-live="polite". Its text is "No matching blocks" only when a session exists with zero items. Otherwise it is empty.
  • The same node is the visible text. So browse mode reads it once. Style it text-base-content/60 px-2 py-3 text-sm only while it has text. Empty, it has no padding.
  • Keep if (!session) for the listbox. Seven Cypress checks treat a missing [data-testid="slash-menu"] as closed.
  • Build option ids from item.id, for example slash-menu-option-heading1. Compute the active id from session.items[session.selectedIndex]?.id.
  • Do not add "speak only when the query changes" logic. A live region speaks only when its text changes, so a remote edit with the same result stays silent.

Where to start:

  • apps/webapp/src/components/TipTap/slash/SlashMenuList.tsx, and slashOptionId in apps/webapp/src/components/TipTap/slash/slashMenuSession.ts. Item ids come from slashItems.ts (heading1 … heading6, subtitle, normal, bulletList, and so on).
  • Update the two id assertions in apps/webapp/cypress/e2e/editor/slash/slash-menu.cy.ts: slash-menu-option-0 (line 81) becomes slash-menu-option-heading1, and slash-menu-option-2 (line 138) becomes slash-menu-option-heading3.
  • Add the "Slash menu popup" entry to .cursor/docs/design-system.md §Floating overlays, before "Mention picker popup". Use the same shape as that entry: a ### heading, a placement line, and a State and Recipe table.
    • frame: popoverPanelClassName plus w-auto min-w-56 overflow-y-auto overscroll-contain. Max height 320px from the size middleware (MAX_LIST_HEIGHT) in apps/webapp/src/components/TipTap/extensions/slash-menu/slash-menu.ts.
    • phone: the slashMenu sheet, title "Insert block", trapFocus: false, so the editor keeps focus and the keyboard.
    • rows: hover:bg-base-200 rounded-field min-h-11 gap-2.5 px-2.5 py-1.5 text-sm. Keyboard-selected adds bg-base-200. Icon 16px at opacity-70.
    • picker status: one polite region outside the listbox and outside .ProseMirror. It mounts empty first. One node is both the visible empty text and the status. It speaks only the empty text, never a count.

Line numbers are hints as of 2026-09-28; the agent searches by symbol.

Rules that apply:

  • apps/webapp/src/components/TipTap/CLAUDE.md §Editor Performance: never put a live region inside .ProseMirror.
  • .cursor/docs/design-system.md §State language, for the row states. AGENTS.md §UI And Theme.
  • AGENTS.md §Test Policy: add no new test. A text-in-a-node check proves no speech. The screen-reader walk is the proof.

Verify:

  • bun run check (it also runs scripts/check-agent-docs.ts over the design-system file). If Prettier flags the new table, run bunx prettier --write .cursor/docs/design-system.md. Do not run bun run format:fix; it writes every unformatted file in the repo.
  • Start the local stack with make dev-local. Note the webapp port; it is not always 3000. From apps/webapp, run bunx cypress run --spec cypress/e2e/editor/slash/slash-menu.cy.ts --env EDITOR_BASE_URL=http://localhost:<port>.
  • Desktop browser, on the /editor playground, in light and dark (set data-theme on <html>):
    • Type /zzz. "No matching blocks" shows in muted ink. In the accessibility tree, the status node is not a child of the listbox.
    • Type /. While rows show, the status node exists, is empty, and has no height.
    • Type /h, note aria-activedescendant on the editor, press Backspace once, then type b. The value changes.
    • Type /zzz, then press Enter. The caret moves to a new, empty line, and /zzz stays as text.
  • Phone: a narrow window does not produce the mobile shell, and the playground has none. Use a real phone, or a mobile user agent on a real pad route. Type /zzz and check the sheet shows the text.
  • Maintainer walk (the last box): NVDA with Chrome, VoiceOver with Safari, and VoiceOver on iPhone. Type /b, /bl, /zzz, then Backspace back to matches. Also open straight into zero rows: type /tabel , then Backspace over the space.

Out of scope

Activity

  1. added
    bugSomething isn't working
    EditorTiptap & Prosemirror
    on Sep 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    EditorTiptap & ProsemirrorUIbugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions