Skip to content

fix: honour the role="menu" arrow-key contract on all five sibling menu surfaces - #6267

Merged
bolichen97 merged 2 commits into
mainfrom
fix/menu-arrow-keys-6231
Aug 28, 2026
Merged

fix: honour the role="menu" arrow-key contract on all five sibling menu surfaces#6267
bolichen97 merged 2 commits into
mainfrom
fix/menu-arrow-keys-6231

Conversation

@dwu96

@dwu96 dwu96 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Fixes #6231

What

Five containers declare role="menu" — which tells assistive technology that arrow keys move between items (WAI-ARIA menu pattern) — and none of them implemented that movement. This PR replicates an in-repo, already-correct implementation rather than designing one: MenuBtn in website/src/pages/DevFleetPage.tsx (landed via #6226) already carries the full contract. Its roving-focus keydown logic is extracted into one shared hook, website/src/hooks/useMenuKeyboard.ts (beside useListboxKeyboard), and the five surfaces are wired onto it:

Surface Adaptation
apps/meetings/components/AgentPillBar.tsx menu semantics on the actual controls: each remove button carries role="menuitem", the footer "Add a link" is a native <button role="menuitem">; a focus-repair effect covers host-driven list refreshes and menu closes; Escape restores focus to the trigger
components/MicSourceMenu.tsx attach-only (rows were already role="menuitemradio" buttons); Escape/selection restore focus to the trigger
apps/mochi/src/renderer/ContextMenu.tsx attach + guarded useLayoutEffect unmount focus restore (opener captured at first render)
apps/mochi/src/renderer/PackEditor.tsx (SlotPopover) attach + closeToOpener(): Escape and every menu command restore focus; outside-pointer dismissal deliberately does not
pierre/PierreWorkspaceTreeImpl.tsx attach-only — a semantic no-op: this menu hosts exactly one menuitem, so there is nothing to navigate between; wired for contract consistency (arrows consumed instead of scrolling the tree; Tab contained), not claimed as a fifth behavioural fix

The contract: ArrowDown/ArrowUp move real DOM focus and wrap at both ends, Home/End jump to boundary items, Tab/Shift-Tab are contained within enabled items (#2533), an IME composition latch keeps candidate-navigation keys out (#5851), and focus enters the menu on open. Focus-entry's dual: every explicit dismissal restores focus (two sanctioned postures, documented on the hook).

Zero i18n changes — no locale bundle touched, no visible label, ordering, or click behaviour changed.

Why not useListboxKeyboard

The issue's DIRECTION section framed this as extend-the-listbox-hook vs extract-a-new-primitive. The three cited properties of useListboxKeyboard are real, but the conclusion drawn from them was false — MenuBtn is a closer, already-correct role="menu" implementation that resolves all three, so the contract was already settled by #6226, not an open design question:

  1. Listbox Tab calls closeToTrigger() (useListboxKeyboard.ts ~106) → MenuBtn contains Tab within enabled items (now useMenuKeyboard.ts, Tab branch).
  2. Listbox ArrowDown clamps, els[i+1] ?? els[els.length-1] (~117), no wrap → MenuBtn wraps at both ends via the % focusable.length modulo.
  3. Listbox scopes its IME guard to the filter input → MenuBtn uses the document-level useDocumentImeLatch, which a menu (no editable target) needs.

useListboxKeyboard is untouched.

Why DevFleetPage.tsx is in the diff

The issue explicitly asks for "no fifth inline spelling". MenuBtn itself is refactored onto the extracted helper — leaving the original inline would have made a sixth spelling. Its existing tests pass unchanged (92/92): that is the extraction's behaviour-preservation proof.

Review-driven hardening (rounds 1–2, both local lanes)

  • mochi ContextMenu: focus entry without restore was a regression — fixed with an opener captured at first render and a guarded useLayoutEffect unmount cleanup. The layout phase is load-bearing: a passive useEffect cleanup runs after the node is detached (activeElement already reset to <body>) and silently never fires — mutation-tested (useLayoutEffectuseEffect flips 3 tests red). The contains guard is not black-box detectable through React's commit path (react-dom's selection-restore masks it); kept for intent and non-commit-path futures, documented in code and tests.
  • AgentPillBar: rows are layout divs, not menuitems (menuitem subclasses command; an inert row is the same promise-not-kept defect class as Five sibling role="menu" surfaces lack the arrow-key navigation their role promises #6231 itself); menu semantics sit on the controls. A focus-repair effect adopts focus only when it genuinely fell to <body> after a host-driven attachments refresh (index-keyed rows remount) or menu close — covered by controlled-host tests that really mutate state.
  • SlotPopover: closeToOpener() centralised; the action → close → focus order is preserved (correct for the Select File native-dialog path).
  • Helper-test fixture rebuilt with DOM APIs to honour the repo's blocking frontend-security rule (no HTML-string writes).

Honest disclosures

  • MicSourceMenu first-open focus target: devices are enumerated when the menu opens (async), so the first open lands on "System default"; subsequent opens land on the first device row. Both pinned by tests. The cause is the focus-entry effect's dependency list — a dependency-shaped follow-up, not a redesign; out of this fix's scope.
  • One pre-existing test edited (MochiPackEditorCoverage.test.tsx): its "unowned key" probe used Escape and asserted the popover stays open — invalidated by the (required) Escape dismissal; probe key moved to inert x.
  • A sixth sibling exists outside this issue's inventory: apps/crew-companion/ContextMenu.tsx has role="menuitem" rows with no role="menu" container. Filed as crew-companion ContextMenu: role="menuitem" rows without a role="menu" container (and no menu keyboard contract) #6266.
  • Tab-containment shape is boundary-keyed (inherited verbatim from MenuBtn — changing it would invalidate the 92/92 extraction proof): an orphaned activeElement escapes via Tab until arrows re-enter. The AgentPillBar focus-repair effect removes the one reachable path to that state in this diff.

Verification

  • Red before green, per surface and per review round: every behavioural test confirmed failing pre-fix (Pierre's single-item case via event-consumption assertions — fireEvent.keyDown(...) === false — since focus assertions are vacuous there).
  • Mutation checks: wiring reverted per surface → red → byte-identical restore; the wrap modulo replaced with a clamp → caught by helper + DevFleetPage tests; focus-repair effect removed → 3 red; useLayoutEffectuseEffect → 3 red.
  • Extraction proof: DevFleetPage's 92 tests pass unchanged.
  • Gates: full website suite 25,010 passed / 0 failed; tsc -b clean; eslint 0 errors, warnings on touched files byte-identical to origin/main.
  • Zero-regression proofs vs an origin/main worktree: electron failing set 43 = 43 byte-identical; backend cross-surface guards 54 = 54 byte-identical (pre-existing environmental; comm empty both directions).
  • Re-verified after each rebase onto moved main (all touched suites 272/272 at fe49a46).

Evidence — focus-trace assertion dump (keyboard behaviour does not screenshot)

✓ AgentPillBar attachment menu keyboard contract > moves focus onto the first remove button when the menu opens
✓ AgentPillBar attachment menu keyboard contract > walks ArrowDown ... wrapping past the footer
✓ AgentPillBar attachment menu keyboard contract > jumps to the boundary items with End and Home
✓ AgentPillBar attachment menu keyboard contract > contains Tab inside the open menu, wrapping at both ends
✓ AgentPillBar attachment menu keyboard contract > announces all three stops as menuitems — role="menu" owns no invalid child
✓ AgentPillBar attachment menu keyboard contract > returns focus to the paperclip trigger on Escape
✓ focus repair (controlled host) > repairs focus onto the first surviving menu item when the focused row is removed
✓ focus repair (controlled host) > returns focus to the paperclip when the host closes the menu after "Add a link"

Why no screenshot: this PR changes keyboard focus behaviour only — no pixel renders differently, so a still frame cannot show the delta. Per the task spec, evidence is the focus-trace assertion dump above (real DOM focus moves asserted per keystroke) instead of a screenshot.

@dwu96
dwu96 requested a review from a team August 27, 2026 10:05
@dwu96
dwu96 requested a review from a team as a code owner August 27, 2026 10:05
@dwu96
dwu96 requested a review from krishdhasmana August 27, 2026 10:05
@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Aug 27, 2026
@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — 🟡 CONCERNS

Design-level review of c1079e2f4be2a5a0a1a4d7c486f8e772ba332b70 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

The hook centralizes focus entry but leaves restore and the single-open-menu invariant as comment-only host obligations — a demonstrated per-consumer trap.

Watch

  • useMenuKeyboard moves focus into the menu but delegates the matching restore to each host, yielding four bespoke restore implementations (prop-transition repair effect, guarded useLayoutEffect unmount cleanup, explicit Escape handlers, closeToOpener). The PR's own history proves the trap: the first wired consumer (mochi ContextMenu) shipped entry-without-restore and was caught only in review ("focus entry without restore was a regression"). Every future consumer re-faces that choice gated only by a doc comment.
  • The "at most one hook-driven menu open at a time" precondition is stated but unenforced; a future consumer that doesn't dismiss on outside pointerdown gets silent cross-menu focus theft, and the doc comment is the only guard.

Suggestions

  • Have the hook capture document.activeElement at enable-time and offer an opt-in restoreOnDisable/restoreTo posture — it would collapse three of the four bespoke restores and make entry-without-restore unrepresentable for the common case.
  • A module-level open-instance counter with a dev-mode warning would turn the singleton precondition from prose into a signal.

[DESIGN-REVIEWED] c1079e2

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — ✅ PASS

UX-level review of c1079e2f4be2a5a0a1a4d7c486f8e772ba332b70 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

UX-Verdict: PASS

Keyboard promise the role="menu" surfaces already advertised is now kept, with focus entry, restore-on-dismissal, and Escape paths all closed — no strings, layout, or pointer behavior changed.

[UX-REVIEWED] c1079e2

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of c1079e2f4be2a5a0a1a4d7c486f8e772ba332b70 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] c1079e2

False positive or not applicable? A repository writer can comment:
/ai-review override gpt c1079e2f4be2a5a0a1a4d7c486f8e772ba332b70: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of c1079e2f4be2a5a0a1a4d7c486f8e772ba332b70 — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All checks done — the contract, intent, patch, and repo verification are complete. Here is the review.

First-Principles-Verdict: CONCERNS

The fix and its extraction are sound; the disclosure undercounts the siblings — ChannelPage.tsx:216 is the same defect class, unfiled, plus five divergent spellings remain.

What this change ships

Intent: make arrow-key navigation work on menus that already announce it to assistive tech (issue #6231) — a FIX.

  1. Meetings attachment menu: arrows/Home/End move focus, Tab contained — justified
  2. Mic source menu: same contract — justified
  3. Mochi context menu: same contract, focus restored on close — justified
  4. Mochi slot popover: same contract — justified
  5. Pierre tree menu: arrows consumed, Tab contained (single item, declared no-op) — justified
  6. Slot popover now closes on Escape — rides along, but derived (Tab containment traps without it)
  7. Explicit dismissals return focus to the trigger/opener on four surfaces — derived dual of focus entry
  8. "Add a link" footer now announces as a menuitem, not a loose button — justified (ARIA owned-children rule)
  9. Attachment menu repairs focus after a removal or host-driven close — derived (controlled component)
  10. Shared useMenuKeyboard/handleMenuKeydown/menuItemsOf, MenuBtn refactored onto it — 6 counted consumers, justified

Watch

[FIRST-PRINCIPLES-REVIEWED] c1079e2

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed c1079e2f4be2a5a0a1a4d7c486f8e772ba332b70 — this comment is updated in place on each push.

Review details

The candidate list contains no candidates, so Step 1 has nothing to falsify. I examined the actual implementation changes (useMenuKeyboard.ts, the focus-entry/restore logic in AgentPillBar, mochi ContextMenu, PackEditor, MicSourceMenu, and the DevFleetPage/Pierre extraction) for grounded defects to add under Step 2. The focus-entry effects are correctly ordered relative to the repair/restore effects, the activeElement === body / contains(...) guards prevent focus theft, useLayoutEffect is used where the synchronous-during-unmount timing is load-bearing, degenerate item lists are handled, and no security/crash/data-loss path is touched. Nothing survives at the 80+ bar.

No findings.

[OPUS-REVIEWED] c1079e2

Verdict parsed from the review's SHA-scoped output markers for commit c1079e2f4be2a5a0a1a4d7c486f8e772ba332b70.

False positive or not applicable? A repository writer can comment:
/ai-review override fable c1079e2f4be2a5a0a1a4d7c486f8e772ba332b70: <one-sentence reason>

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 27, 2026
@dwu96

dwu96 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Review round 1 disposition (head e5d8bdd)

Both local model-pinned lanes (GPT 5.6, Opus) blocked on head aa6dcdd; every finding was verified against source before acting. All addressed:

  1. mochi ContextMenu focus orphaning (both lanes, High) — ADOPTED, with two empirical corrections to the reviewer's named design: a passive useEffect cleanup runs after the commit, when the menu node is already detached and activeElement has reset to <body>, so the guarded restore written with useEffect type-checks and silently never fires; and menuRef.current is nulled during teardown. Fix: capture the node at mount + useLayoutEffect cleanup (runs synchronously during the deletion traversal, while the menu still holds focus). Covers Escape, row activation, window-blur close, and host-driven unmount in one place; guarded so an action that legitimately moved focus keeps it. Tests go through a real unmounting host (the old mocked-onClose tests could not see this); mutation matrix: restore removed → 3 red; useLayoutEffectuseEffect → 3 red (proving the phase is load-bearing); guard dropped → not black-box detectable, because react-dom's own selection-restore re-focuses a still-in-document element after commit — the guard is kept for intent and for any future non-commit-path dismissal, and this limit is documented in the code and tests.

  2. AgentPillBar inert role="menuitem" rows (both lanes) — ADOPTED per the Opus direction: menu semantics moved onto the actual controls. Rows revert to plain layout divs; each remove button (native <button>) carries role="menuitem"; the footer Clickable cannot carry it (its props Omit role) and remains a [role="button"] stop, documented inline. Traversal is now one stop per actionable item, with new activation coverage: every discovered stop is clicked and asserted to fire its callback (onRemoveAttachment with the right index, onAddAttachment). Note: the GPT lane's claim that menuitem descendants are ARIA-presentational was checked and is false (ARIA 1.2 §7.1 does not list menuitem); the fix was made on the promise-not-kept ground, which both lanes agreed on.

  3. SlotPopover restores focus on Escape only (GPT, Medium) — ADOPTED: closeToOpener() centralised and used by Escape and all three command paths (Select File, copy-source rows, Clear), preserving each row's existing action(); close ordering. Outside-pointer dismissal still calls plain onClose() — a pointer dismissal must not yank focus from the click target (Dev Fleet confirm popover does not trap focus #2533 posture). Red-before-green: both command paths genuinely dropped focus to <body> pre-fix; path-specific mutation check passed.

Opus Low advisories: dead getItems API removed from useMenuKeyboard; the at-most-one-open-menu precondition is now documented on the hook (with the current-hosts justification); the MicSourceMenu first-open note (dependency-shaped, not a redesign) stays disclosed in the PR body.

Verification on the combined head: tsc -b clean, eslint 0 errors, all 7 touched suites 266/266. Both local lanes re-dispatched on e5d8bdd.

🤖 AI-assisted comment 🤖

…nu surfaces

Fixes #6231

Five containers declare role="menu" — which promises WAI-ARIA arrow-key
item navigation — and none implemented it. Extract MenuBtn's proven
roving-focus keydown logic (DevFleetPage, #6226) into a shared
useMenuKeyboard hook (arrows wrap at both ends, Home/End, Tab containment
per #2533, document IME latch), refactor MenuBtn onto it (its tests pass
unchanged — the extraction's behaviour-preservation proof), and wire the
five surfaces: AgentPillBar (menu semantics on the actual controls, with
host-driven focus repair), MicSourceMenu, mochi ContextMenu (guarded
useLayoutEffect unmount focus restore), mochi PackEditor SlotPopover
(closeToOpener for Escape and every command), and PierreWorkspaceTreeImpl
(single-item semantic no-op, wired for contract consistency).

Zero i18n changes; no visible label, ordering, or click behaviour changed.
@dwu96
dwu96 force-pushed the fix/menu-arrow-keys-6231 branch from e5d8bdd to fe49a46 Compare August 27, 2026 10:51
@dwu96

dwu96 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Review round 2 disposition (head fe49a46, squashed)

Both lanes verified all round-1 findings resolved at e5d8bdd. Round-2 findings, all addressed:

  1. .innerHTML = write in the new helper test (Opus, BLOCKING by repo rule) — ADOPTED: fixture rebuilt with document.createElement/setAttribute/append; assertion unchanged. This was the only innerHTML write in the website test corpus and the frontend-security rule has no test exemption — thanks for the catch; an override would have been the wrong tool.
  2. AgentPillBar footer invalid child of role="menu" (GPT Medium) — ADOPTED: the footer is now a native <button type="button" role="menuitem"> (same label, same look); getAllByRole('menuitem') asserts all three stops are announced menuitems (red before the swap).
  3. AgentPillBar post-command focus stranding (GPT Medium / Opus Low) — ADOPTED: a focus-repair effect adopts focus only when document.activeElement === document.body — after a host-driven attachments refresh while open (→ first surviving menu item, else trigger) or a host-driven close (→ trigger, gated on previous-render-open so mount/unrelated renders never steal focus). Controlled-host tests really remove attachments / close the menu; negative tests pin that deliberate focus placement is never stolen. Mutation: effect removed → exactly the 3 behaviour tests red.
  4. Opus Low advisories — opener refs now use undefined as the not-yet-captured sentinel (null-activeElement ambiguity removed); the hook docblock documents the two sanctioned dismissal postures so the next consumer picks one on purpose; the stale PR body has been rewritten for the current head (surface table, evidence block).

Note per PR hygiene the branch is squashed to one commit; the review-round history lives in these disposition comments.

Verification at fe49a46 (rebased onto current main): tsc -b clean, all 7 touched suites 272/272. Both local lanes re-dispatched.

🤖 AI-assisted comment 🤖

@github-actions github-actions Bot added readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention labels Aug 27, 2026
@dwu96

dwu96 commented Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Advisory dispositions (head c1079e2)

First Principles 🟡 — sibling undercount: ADOPTED — the unfiled ChannelPage.tsx:216 instance (no keyboard handling at all, exact #6231 defect class) is now filed as #6269, with the lane's fuller adoption inventory (WindowsTitlebarMenu / SessionFlyout / SlotTagPopover inline spellings; BusySendButton / MarkdownPanel on the listbox hook) recorded there. Adoption is deliberately deferred work: each surface needs its own red-test round, and widening this PR mid-review would outrun review coverage. Precondition unenforced: acknowledged as stated — a registry/dev-warning is an addition beyond this fix's scope; tracked in the Design lane's suggestion below.

Design 🟡 — restore is a per-host obligation / singleton precondition is prose: acknowledged and agreed-but-separable. The suggested restoreOnDisable hook posture and a dev-mode open-instance counter are real API design work on a shared hook that five surfaces just adopted; doing it inside this PR would rewrite all five wirings in the same diff that review already covered three rounds of. If maintainers want it, it stacks cleanly on this PR as a follow-up (happy to file/execute on request).

🤖 AI-assisted comment 🤖

@github-actions github-actions Bot added readiness: passed Eligible automated validation passed for the current revision and removed readiness: checking Automated validation is still running labels Aug 27, 2026
@bolichen97
bolichen97 enabled auto-merge (squash) August 28, 2026 08:38
@bolichen97
bolichen97 merged commit f6904b3 into main Aug 28, 2026
73 of 75 checks passed
@bolichen97
bolichen97 deleted the fix/menu-arrow-keys-6231 branch August 28, 2026 09:00
@github-actions github-actions Bot removed the readiness: passed Eligible automated validation passed for the current revision label Aug 28, 2026
NicholasRBowers added a commit that referenced this pull request Aug 28, 2026
The agents-panel listen-mode dropdown declares role="menu" — which
promises WAI-ARIA arrow-key item navigation — but wired no keyboard
handling at all: the defect class #6231 fixed on its five inventoried
surfaces via the shared useMenuKeyboard hook; ChannelPage was outside
that inventory (#6269, the ChannelPage analogue of #6266).

Wire the menu onto the merged hook (arrow walk with wrap, Home/End,
Tab containment, document IME latch), mark the rows role=menuitemradio
with aria-checked so the current mode is perceivable programmatically
rather than by colour alone, name the group (aria-label, new catalog
key) so the announced radio state has a referent, and restore focus to
the trigger on explicit dismissal (Escape / row activation), matching
the MicSourceMenu posture from #6267. Outside-click dismissal is left
alone, per the same posture. Focus entry is host-owned with
preventScroll: this menu is not portalled and lives inside the agents
rail's scroll container, so the hook's default entry would scroll the
rail on every open.

Co-authored-by: Kiro Crew <kirocrew@users.noreply.github.com>
bolichen97 pushed a commit that referenced this pull request Aug 28, 2026
…6547)

The agents-panel listen-mode dropdown declares role="menu" — which
promises WAI-ARIA arrow-key item navigation — but wired no keyboard
handling at all: the defect class #6231 fixed on its five inventoried
surfaces via the shared useMenuKeyboard hook; ChannelPage was outside
that inventory (#6269, the ChannelPage analogue of #6266).

Wire the menu onto the merged hook (arrow walk with wrap, Home/End,
Tab containment, document IME latch), mark the rows role=menuitemradio
with aria-checked so the current mode is perceivable programmatically
rather than by colour alone, name the group (aria-label, new catalog
key) so the announced radio state has a referent, and restore focus to
the trigger on explicit dismissal (Escape / row activation), matching
the MicSourceMenu posture from #6267. Outside-click dismissal is left
alone, per the same posture. Focus entry is host-owned with
preventScroll: this menu is not portalled and lives inside the agents
rail's scroll container, so the hook's default entry would scroll the
rail on every open.

Co-authored-by: Nick Bowers <1668224+NicholasRBowers@users.noreply.github.com>
Co-authored-by: Kiro Crew <kirocrew@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Five sibling role="menu" surfaces lack the arrow-key navigation their role promises

2 participants