Skip to content

[LUM-654] Implement M3 keyboard focus system for VMenu components#23277

Merged
Jasonnnz merged 11 commits into
mainfrom
devin/1775158060-m3-keyboard-focus
Apr 2, 2026
Merged

[LUM-654] Implement M3 keyboard focus system for VMenu components#23277
Jasonnnz merged 11 commits into
mainfrom
devin/1775158060-m3-keyboard-focus

Conversation

@devin-ai-integration
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot commented Apr 2, 2026

Summary

Completes the partially scaffolded keyboard navigation system in VMenuCoordinator so that VMenu panels support full arrow-key navigation, Enter/Space activation, right-arrow submenu opening, and VoiceOver bridging — matching native NSMenu keyboard behavior.

Architecture: SwiftUI-native focus (WWDC23)

After multiple iterations where AppKit-level event interception (NSEvent monitors) failed to reliably bridge state changes back to SwiftUI, this PR adopts Apple's recommended approach from WWDC23 "The SwiftUI cookbook for focus":

  1. Primary pathVMenu's VStack is .focusable() with .onKeyPress() handlers. Key events are processed entirely within SwiftUI's rendering cycle, and focus state is a @State focusedItemID: UUID? — guaranteed to trigger re-renders.
  2. Fallback path — If VMenu loses SwiftUI focus, VMenuPanel.keyDown catches events via the responder chain and publishes via Combine (focusChangeSubject/clearFocusSubject), which VMenu subscribes to with .onReceive.
  3. Environment propagationfocusedItemID flows from VMenu to children via \.vMenuFocusedItemID environment key. VMenuItem and VSubMenuItem derive isKeyboardFocused as a computed property (menuFocusedItemID == itemID), so highlighting is reactive without any Combine subscriptions in items.

Key changes by file:

  • VMenuCoordinator: Adds UUID-based item registration (itemOrder, itemActions, submenuActions, itemNSViews), Combine subjects for the fallback focus-change path, keyboard activation for Enter/Space/→, a mouse-move monitor with 200ms debounce after key events, and a VoiceOver bridge via NSAccessibility.post(element:notification:). The local keyboard monitor tracks event timing only — it does not consume events.

  • VMenu.swift: Adds VMenuPanelLevelKey and VMenuFocusedItemIDKey environment keys, VMenuItemRegistrationKey preference key for item ordering, VMenuItemNSViewCapture (NSViewRepresentable) for VoiceOver NSView tracking, and the .focusable() + .onKeyPress() handler with navigation helpers. VMenuItem and VSubMenuItem read focus state from environment and register themselves with the coordinator on appear.

  • VNavItem.swift: Adds isKeyboardFocused parameter (cross-platform, defaults to false) for keyboard focus highlight. On iOS the parameter is a no-op. Extracts background color into a navItemBackground computed property.

  • VMenuPanel.swift: Injects vMenuPanelLevel environment (0 for root, panels.count for child) so items know their nesting depth.

All changes are additive — existing mouse interaction, hover behavior, and visual appearance are unchanged.

Review & Testing Checklist for Human

⚠️ High risk — no local Swift compilation was possible; this is the first build verification. Three prior architectural approaches (AppKit event monitors → @Observable → Combine publishers) failed to produce working keyboard navigation. This fourth approach (SwiftUI-native .focusable() + .onKeyPress()) is architecturally sound per Apple's guidance but is untested.

  • Verify compilation on both macOS and iOS targets. CI skips build jobs on this branch. This is the first compilation verification. Watch for: environment key access levels, #if os(macOS) boundary correctness, @FocusState availability.
  • Verify .focusable() + .onKeyPress() works inside NSPanel. The core bet is that SwiftUI's focus system works when hosted in an NSHostingView that is the contentView of an NSPanel (not a standard NSWindow). The .task sets isMenuFocused = true after 50ms — confirm this actually grants focus.
  • Manual keyboard navigation test:
    1. Right-click to open a context menu
    2. Press ↑/↓ — items should highlight sequentially with a blue accent background
    3. Press Enter or Space — focused item should activate and dismiss the menu
    4. Navigate to a submenu item, press → — child panel should open with focus on first item
    5. Press ← — child should close, focus returns to parent
    6. Move mouse — keyboard highlight should disappear
  • Test action closure freshness. Item actions are registered in .onAppear and never re-registered. If an action closure captures state that changes after appear (e.g., a binding), the registered action may be stale. Verify activation triggers the correct/current action.
  • VoiceOver + keyboard test: Enable VoiceOver (⌘F5), open a menu, use arrow keys — VoiceOver should announce each item as focus moves. findAccessibleElement(from:) walks up the NSView hierarchy to find the accessible element; verify it targets the correct element.
  • Remove debug print statements before merging. The diff contains numerous print("[VMenuKeyboard] ...") diagnostic statements. These should be removed or gated behind a DEBUG flag before shipping.

Notes

  • The VMenuItemNSViewCapture is a zero-frame invisible NSView embedded via NSViewRepresentable. Its sole purpose is to capture a view reference for VoiceOver's NSAccessibility.post(element:notification:). It is marked setAccessibilityElement(false) to stay out of the accessibility tree.
  • The mouse-move monitor (NSEvent.addLocalMonitorForEvents(matching: .mouseMoved)) fires for all mouse movement in the app, not just over the menu panel. A 200ms debounce after the last keyboard event prevents trackpad micro-jitter from immediately clearing focus. This matches native NSMenu behavior where mouse movement exits keyboard mode.
  • WeakNSViewRef wraps NSView references to avoid retain cycles between the coordinator and the view hierarchy.
  • isKeyboardFocused is a public property on VNavItem on all platforms (not conditionally compiled) because Swift does not support #if directives inside function parameter lists. On iOS it defaults to false and has no effect.
  • The VMenuFocusedItemIDKey environment key has internal access (not public), which is sufficient since all consumers are within the same DesignSystem module.

Link to Devin session: https://app.devin.ai/sessions/f5b6760008ac4fa7867c752eeb3bf0e7
Requested by: @Jasonnnz


Open with Devin

- Convert VMenuCoordinator to @observable for reactive focus state tracking
- Add UUID-based item registration system (VMenuItemRegistrationKey preference key)
- Implement keyboard activation (Enter/Space) and submenu opening (right arrow)
- Add VoiceOver bridge: post focusedUIElementChanged on keyboard focus moves
- Install mouse move monitor to clear keyboard focus (matches native NSMenu)
- Add visual keyboard focus highlight to VMenuItem, VSubMenuItem, and VNavItem
- Add VMenuItemNSViewCapture (NSViewRepresentable) for VoiceOver NSView tracking
- Inject panel level via environment for multi-level keyboard navigation
- Add isKeyboardFocused parameter to VNavItem for .regular size menu items

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
@linear
Copy link
Copy Markdown

linear Bot commented Apr 2, 2026

LUM-654 Accessibility for VMenu and VSubmenu

Ensure that the new VMenu and VSubMenu and VMenuItem components are following best accessibility practices

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

⚙️ Control Options:

  • Disable automatic comment and CI monitoring

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it.

5 similar comments
@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it.

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it.

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it.

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it.

@devin-ai-integration
Copy link
Copy Markdown
Contributor Author

Devin is archived and cannot be woken up. Please unarchive Devin if you want to continue using it.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 5 commits April 2, 2026 20:01
Swift does not support conditional compilation directives inside function
parameter lists or call sites. Make isKeyboardFocused available on all
platforms (defaults to false, no-op on iOS) instead of conditionally
compiling it. Use an immediately-invoked closure for the VMenu call site
to conditionally pass the value.

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
Three fixes for arrow keys not highlighting menu items:

1. Mouse move monitor debounce: Ignore mouse movements within 200ms of a
   keyboard event to prevent trackpad/mouse micro-jitter from clearing
   the focus highlight before SwiftUI renders it.

2. Belt-and-suspenders item registration: registerItemAction() now also
   appends the item to itemOrder/itemCounts as a fallback, ensuring
   moveFocus() has a non-zero count even if onPreferenceChange hasn't
   fired yet.

3. Timestamp tracking: handleKeyDown() records lastKeyboardEventTime
   before processing, used by the mouse move debounce guard.

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
The panel's keyDown override was not receiving key events — likely because
NSHostingView intercepts them in the responder chain, or addChildWindow
changes key window status away from the panel.

Fix: Install NSEvent.addLocalMonitorForEvents(matching: .keyDown) on the
coordinator when the root panel opens. This intercepts key events BEFORE
the responder chain, the same approach native NSMenu uses. The monitor
returns nil to consume handled events (arrow keys, Enter, Space) and
passes through all other keys.

Reference: https://developer.apple.com/documentation/appkit/nsevent/addlocalmonitorforevents(matching:handler:)
Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
…vigation

Root cause: previous attempts used AppKit NSEvent monitors to catch key
events then tried to bridge state changes back to SwiftUI via @observable
or Combine. This AppKit→SwiftUI bridge is fundamentally fragile — SwiftUI
may not re-render when coordinator properties change through external
event monitors.

Fix: Use Apple's recommended approach from WWDC23 'The SwiftUI cookbook
for focus' (https://developer.apple.com/videos/play/wwdc2023/10162/):

1. VMenu's VStack is .focusable() with .onKeyPress() handlers — key
   events are handled entirely within SwiftUI's rendering cycle.
2. Focus state is VMenu's @State focusedItemID — guaranteed re-renders.
3. focusedItemID flows to children via environment (vMenuFocusedItemID).
4. VMenuItem/VSubMenuItem read from environment — no Combine needed.
5. NSEvent keyboard monitor only tracks timing (for mouse-move debounce),
   does NOT consume events.
6. VMenuPanel.keyDown remains as fallback if VMenu loses focus — it calls
   coordinator.handleKeyDown which publishes via Combine, and VMenu
   subscribes to update its @State.

References:
- https://developer.apple.com/videos/play/wwdc2023/10162/
- https://developer.apple.com/documentation/swiftui/view/focusable(_:interactions:)
- https://developer.apple.com/documentation/swiftui/view/onkeypress(_:action:)-14rhi

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…nAppear

Root cause: VMenuPanel.show() calls registerRootPanel() AFTER
NSHostingView triggers SwiftUI layout, which means .onAppear has
already fired and registered item actions. registerRootPanel() then
clears itemActions/submenuActions/itemNSViews, permanently wiping
the registered actions (.onAppear only fires once).

Up/Down worked because VMenu uses its own @State registeredIDs
(populated by async onPreferenceChange which fires after
registerRootPanel). But Enter/Space/Right/Left depend on
coordinator.itemActions which was being wiped.

Fix: Only reset transient navigation state (focusedIndex) in
registerRootPanel. The coordinator is freshly created per panel
tree, so action dictionaries start empty and don't need clearing.

Also: Change keyboard focus highlight from Color.accentColor (blue)
to VColor.systemPositiveWeak (light green design system token).

Co-Authored-By: Jason Zhou <jasonczhou3@gmail.com>
devin-ai-integration[bot]

This comment was marked as resolved.

…ld auto-focus, remove debug prints

- Block keyboard activation (Enter/Space) for disabled menu items by tracking
  isEnabled state per item in the coordinator (#1, #2)
- Include panel level in focusChangeSubject so fallback-path focus changes
  only affect the correct VMenu instance, preventing parent corruption (#4)
- Replace synchronous moveFocus into child (which raced SwiftUI preference
  propagation) with a pendingChildFocus flag consumed by the child VMenu's
  .task after layout settles (#3, #6)
- Remove all print("[VMenuKeyboard]...") debug statements (#5)
- Forward isKeyboardFocused in VNavItemTrailingIcon convenience init (#7)

Co-Authored-By: Claude <noreply@anthropic.com>
@Jasonnnz
Copy link
Copy Markdown
Contributor

Jasonnnz commented Apr 2, 2026

@codex review this PR again — the previous issues have been fixed in commit b132b8f

@Jasonnnz
Copy link
Copy Markdown
Contributor

Jasonnnz commented Apr 2, 2026

@devin review this PR again — the previous issues have been fixed in commit b132b8f

chatgpt-codex-connector[bot]

This comment was marked as resolved.

devin-ai-integration[bot]

This comment was marked as resolved.

…m enabled state, fix mouse monitor

- Add isItemEnabled check to right-arrow paths (both primary openMenuSubmenu
  and fallback handleKeyDown case 124) so disabled VSubMenuItems can't be
  opened via keyboard
- Register VSubMenuItem enabled state with coordinator on appear and
  onChange(of: isEnabled), matching VMenuItem's existing pattern
- Set acceptsMouseMovedEvents = true on VMenuPanel so the coordinator's
  local mouse-move monitor fires when the cursor is over the panel,
  allowing keyboard focus to be properly cleared

Co-Authored-By: Claude <noreply@anthropic.com>
@Jasonnnz
Copy link
Copy Markdown
Contributor

Jasonnnz commented Apr 2, 2026

@codex review this PR again — the previous issues have been fixed in commit 9743fa7

@Jasonnnz
Copy link
Copy Markdown
Contributor

Jasonnnz commented Apr 2, 2026

@devin review this PR again — the previous issues have been fixed in commit 9743fa7

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Chef's kiss.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

devin-ai-integration[bot]

This comment was marked as resolved.

Vellum Assistant and others added 2 commits April 2, 2026 18:03
VMenuItem and VSubMenuItem icons now use VColor.contentDisabled when
the item is disabled, matching the text color behavior. Previously
icons kept their normal color (primaryBase/primaryActive) even when
disabled, making it harder to visually identify disabled items.

Co-Authored-By: Claude <noreply@anthropic.com>
Add `guard !Task.isCancelled else { return }` after `Task.sleep` to
prevent stale state mutation when the view is dismissed before the
50ms delay completes. Without this, a cancelled task could consume
the pendingChildFocus flag meant for a subsequent child menu.

Co-Authored-By: Claude <noreply@anthropic.com>
@Jasonnnz
Copy link
Copy Markdown
Contributor

Jasonnnz commented Apr 2, 2026

@codex review this PR again — the previous issues have been fixed in commit 4349584

@Jasonnnz
Copy link
Copy Markdown
Contributor

Jasonnnz commented Apr 2, 2026

@devin review this PR again — the previous issues have been fixed in commit 4349584

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4349584b95

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

guard !Task.isCancelled else { return }
isMenuFocused = true
// If this child menu was opened via keyboard (→ arrow), auto-focus the first item.
if panelLevel > 0, coordinator?.consumePendingChildFocus() == true, !registeredIDs.isEmpty {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Delay consuming child-focus flag until items are registered

consumePendingChildFocus() is evaluated before the !registeredIDs.isEmpty guard, so the pending flag is cleared even when the submenu has no registered rows yet. If a submenu opens from keyboard navigation while its items register later (for example, delayed/conditional content), the first-item auto-focus is permanently skipped and arrow/enter handling starts with no focused row. Check registeredIDs first (or retry on registration change) before consuming the pending focus flag.

Useful? React with 👍 / 👎.

@Jasonnnz Jasonnnz merged commit 4a322aa into main Apr 2, 2026
7 checks passed
@Jasonnnz Jasonnnz deleted the devin/1775158060-m3-keyboard-focus branch April 2, 2026 22:12
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.

1 participant