fix: honour the role="menu" arrow-key contract on all five sibling menu surfaces - #6267
Conversation
Design Review (Fable 5) — 🟡 CONCERNSDesign-level review of 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
Suggestions
[DESIGN-REVIEWED] c1079e2 |
UX Review (Fable 5) — ✅ PASSUX-level review of UX-Verdict: PASS Keyboard promise the [UX-REVIEWED] c1079e2 |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
First Principles Review (Fable 5) — 🟡 CONCERNSPremise-level review of 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 — What this change shipsIntent: make arrow-key navigation work on menus that already announce it to assistive tech (issue #6231) — a FIX.
Watch
[FIRST-PRINCIPLES-REVIEWED] c1079e2 |
Opus 4.8 Review — ✅ no blocking findingsReviewed Review detailsThe candidate list contains no candidates, so Step 1 has nothing to falsify. I examined the actual implementation changes ( No findings. [OPUS-REVIEWED] c1079e2 Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
Review round 1 disposition (head
|
…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.
e5d8bdd to
fe49a46
Compare
Review round 2 disposition (head
|
Advisory dispositions (head
|
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>
…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>
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:MenuBtninwebsite/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(besideuseListboxKeyboard), and the five surfaces are wired onto it:apps/meetings/components/AgentPillBar.tsxrole="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 triggercomponents/MicSourceMenu.tsxrole="menuitemradio"buttons); Escape/selection restore focus to the triggerapps/mochi/src/renderer/ContextMenu.tsxuseLayoutEffectunmount focus restore (opener captured at first render)apps/mochi/src/renderer/PackEditor.tsx(SlotPopover)closeToOpener(): Escape and every menu command restore focus; outside-pointer dismissal deliberately does notpierre/PierreWorkspaceTreeImpl.tsxThe 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
i18nchanges — no locale bundle touched, no visible label, ordering, or click behaviour changed.Why not
useListboxKeyboardThe issue's DIRECTION section framed this as extend-the-listbox-hook vs extract-a-new-primitive. The three cited properties of
useListboxKeyboardare real, but the conclusion drawn from them was false —MenuBtnis a closer, already-correctrole="menu"implementation that resolves all three, so the contract was already settled by #6226, not an open design question:closeToTrigger()(useListboxKeyboard.ts~106) →MenuBtncontains Tab within enabled items (nowuseMenuKeyboard.ts, Tab branch).els[i+1] ?? els[els.length-1](~117), no wrap →MenuBtnwraps at both ends via the% focusable.lengthmodulo.MenuBtnuses the document-leveluseDocumentImeLatch, which a menu (no editable target) needs.useListboxKeyboardis untouched.Why
DevFleetPage.tsxis in the diffThe issue explicitly asks for "no fifth inline spelling".
MenuBtnitself 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)
useLayoutEffectunmount cleanup. The layout phase is load-bearing: a passiveuseEffectcleanup runs after the node is detached (activeElement already reset to<body>) and silently never fires — mutation-tested (useLayoutEffect→useEffectflips 3 tests red). Thecontainsguard 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.menuitemsubclassescommand; 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.closeToOpener()centralised; the action → close → focus order is preserved (correct for the Select File native-dialog path).Honest disclosures
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 inertx.apps/crew-companion/ContextMenu.tsxhasrole="menuitem"rows with norole="menu"container. Filed as crew-companion ContextMenu: role="menuitem" rows without a role="menu" container (and no menu keyboard contract) #6266.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
fireEvent.keyDown(...) === false— since focus assertions are vacuous there).useLayoutEffect→useEffect→ 3 red.tsc -bclean; eslint 0 errors, warnings on touched files byte-identical toorigin/main.origin/mainworktree: electron failing set 43 = 43 byte-identical; backend cross-surface guards 54 = 54 byte-identical (pre-existing environmental;commempty both directions).fe49a46).Evidence — focus-trace assertion dump (keyboard behaviour does not screenshot)
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.