feat: more accessible keyboard nav focus outlines for menu items#890
feat: more accessible keyboard nav focus outlines for menu items#890dustinhealy merged 7 commits intomainfrom
Conversation
Add `stroke.focus` to genericMenu item color tokens for both default and danger variants in light and dark themes, scoped to the component namespace rather than reaching into global.color.outline.
Tracks input modality on a menu container via React event props so descendant CSS can show focus rings only during keyboard navigation, avoiding visual disruption on hover (not required by WCAG).
Wire useInputModality into Dropdown, ContextMenu, and Select containers and add a CSS outline rule on GenericMenuItem that activates only during keyboard navigation, fixing WCAG SC 2.4.7 (Focus Visible) and SC 1.4.11 (Non-text Contrast) violations.
🦋 Changeset detectedLatest commit: 3cc7ace The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
|
Claude finished @dustinhealy's task in 2m 20s —— View job PR Review: feat: more accessible keyboard nav focus outlines for menu itemsSolid a11y improvement — the approach of using a 🚨 Must Fix
🧪 Testing
💡 Suggestions (non-blocking)
Overall a clean, well-reasoned implementation. The primary blocker is the missing changeset; please add one before merging. |
@dustinhealy Thanks for your contribution! Mind add a changeset for these changes, please? https://github.com/clickHouse/click-ui?tab=readme-ov-file#add-a-new-changeset |
There was a problem hiding this comment.
Pull request overview
This PR improves WCAG-compliant keyboard focus visibility for menu items across Dropdown, Select, and ContextMenu by introducing theme focus-stroke tokens and an input-modality mechanism that allows CSS to distinguish keyboard focus from pointer hover.
Changes:
- Added
stroke.focustokens undergenericMenu.item.colorfor light/dark themes (default + danger variants). - Introduced
useInputModalityhook to setdata-input-modalityon menu containers without React re-renders. - Updated
GenericMenuItemstyling to render an inset 2px outline only for keyboard modality when Radix setsdata-highlighted, and wired the modality props into Dropdown/ContextMenu/Select popover content.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| src/theme/tokens/variables.light.ts | Adds stroke.focus tokens for generic menu items in the light theme. |
| src/theme/tokens/variables.dark.ts | Adds stroke.focus tokens for generic menu items in the dark theme. |
| src/hooks/useInputModality.ts | New hook that tracks keyboard vs pointer interaction via event handlers and sets data-input-modality. |
| src/hooks/index.ts | Exports useInputModality from the hooks barrel. |
| src/components/Select/common/InternalSelect.tsx | Applies input-modality props to SelectPopoverContent. |
| src/components/GenericMenu/GenericMenu.tsx | Adds keyboard-only outline styling for highlighted menu items using new focus-stroke tokens. |
| src/components/Dropdown/Dropdown.tsx | Applies input-modality props to dropdown content container. |
| src/components/ContextMenu/ContextMenu.tsx | Applies input-modality props to context menu content container. |
Comments suppressed due to low confidence (2)
src/components/Dropdown/Dropdown.tsx:127
...propsis spread after...inputModalityProps, so any consumer-providedonKeyDownCapture/onPointerMovewill override the modality tracking and can silently remove the keyboard-only focus outlines. Consider composing handlers or moving...propsbefore...inputModalityPropsso the accessibility behavior is preserved.
<DropdownMenuContent
{...inputModalityProps}
$type="dropdown-menu"
$showArrow={showArrow}
as={ContentElement}
sideOffset={4}
loop
avoidCollisions={responsivePositioning}
collisionPadding={responsivePositioning ? 100 : undefined}
{...props}
>
src/components/ContextMenu/ContextMenu.tsx:134
...propsis spread after...inputModalityProps, so consumer-providedonKeyDownCapture/onPointerMovecan override the modality tracking and effectively disable the keyboard-only focus outline behavior. Consider composing the event handlers or changing spread order to ensure modality tracking can’t be accidentally removed.
<RightMenuContent
{...inputModalityProps}
$type="context-menu"
$showArrow={showArrow}
as={ContentElement}
{...props}
>
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Broaden keydown condition to exclude only modifier keys, covering typeahead characters and Space alongside navigation keys - Add onPointerDown so clicking without mouse movement correctly clears keyboard modality - Add global modality tracker and onFocusCapture prop so menus opened via keyboard show the focus ring on the first highlighted item - Add co-located unit tests for the hook - Add changeset
|
Claude finished @dustinhealy's task in 1m 43s —— View job PR Review: feat: more accessible keyboard nav focus outlines for menu itemsGreat work — the changeset and tests are now in place, and the overall approach is clean and idiomatic. Here's what I found in the updated code: 🚨 Must Fix
💡 Suggestions (non-blocking)
✅ What's good
The only blocker is the props spread order in Dropdown and ContextMenu. Once that's fixed, this is ready to merge. |
|
Claude finished @dustinhealy's task in 1m 17s —— View job PR Review: feat: more accessible keyboard nav focus outlines for menu itemsGreat a11y work — the architecture is clean, the global modality seeding elegantly handles the "menu opened via keyboard" case, and the tests are thorough. One blocker remains from the previous review round. 🚨 Must Fix
✅ What's good
💡 Suggestions (non-blocking)
Once the spread order is fixed in Dropdown and ContextMenu, this is ready to merge. |
|
Claude finished @dustinhealy's task in 1m 11s —— View job PR Review: feat: more accessible keyboard nav focus outlines for menu itemsGreat a11y work — the spread order fix is in place, the implementation is clean, and the tests are comprehensive. Here's my final assessment: ✅ Previously flagged blocker: resolved The
Consumer-provided handlers can no longer silently override the modality tracking behavior. ✅ What's good
💡 Suggestions (non-blocking)
This is ready to merge. 🙌 |
|
@punkbit Comments all addressed + Changeset added. Ready for your review |
|
Claude finished @dustinhealy's task in 1m 10s —— View job PR Review: feat: more accessible keyboard nav focus outlines for menu itemsSolid a11y improvement — the implementation is clean, well-tested, and all previously flagged blockers have been resolved. Here's the current state: ✅ All blockers resolved
✅ What's good
💡 Suggestions (non-blocking)
This is ready to merge. Nice work on a well-reasoned, complete a11y fix! 🙌 |
📚 Storybook Preview Deployed✅ Preview URL: https://click-d6beokm1z-clickhouse.vercel.app Built from commit: |
| }, | ||
| stroke: { | ||
| default: 'rgba(0, 0, 0, 0)', | ||
| focus: '#437eef', |
There was a problem hiding this comment.
@dustinhealy I missed this one. For tokens, we have to update them in the Figma file. The process will be revised soon to avoid confusion, but just for your interest, as it seems these values were hard typed, so lost on the latest.
There was a problem hiding this comment.
Good to know - thanks Helder. In the meantime, feel free to let me know if I need to make any changes / open a new PR to account for this or anything else.
There was a problem hiding this comment.
@dustinhealy Not really, did a quick tweak for now #810 (started migrating out from styled components). Once I get the semantic versions for the tokens to reduce the file sizes and make it simpler, I'll tag you. Thanks!
Why?
Menu items in Dropdown, Select, and ContextMenu have no visible keyboard focus indicator, violating WCAG SC 2.4.7 (Focus Visible) and SC 1.4.11 (Non-text Contrast).
outline: noneis hardcoded onGenericMenuItemand the only visual change on keyboard focus is a background shift from#ffffffto#f6f7fa(~1.02:1 contrast), which is effectively invisible and far below the required 3:1.How?
stroke.focustokens togenericMenu.item.colorin both light (#437eef) and dark (#faff69) themes for default and danger variantsuseInputModalityhook that tracks keyboard vs pointer interaction via React event props on the menu container, settingdata-input-modalityon the element without causing re-rendersGenericMenuItemCSS to show a 2px insetoutline(HCM-compatible) only when the ancestor container hasdata-input-modality="keyboard", keeping hover unaffected since WCAG does not mandate hover contrastuseInputModalityintoDropdownContent,ContextMenuContent, andSelectPopoverContentTickets?
Contribution checklist?
buildcommand runs locallySecurity checklist?
dangerouslySetInnerHTMLPreview?
Screen.Recording.2026-03-09.at.6.41.33.AM.mov
Screen.Recording.2026-03-09.at.6.40.38.AM.mov