feat(core): Add focus management primitives for 2nd-gen architecture#6129
feat(core): Add focus management primitives for 2nd-gen architecture#6129caseyisonit wants to merge 25 commits intomainfrom
Conversation
Phase 1 of focus management migration (FOCUS-MANAGEMENT-PROPOSAL.md §5). - Add get-active-element.ts: shadow DOM-aware activeElement traversal - Add focusable-selectors.ts: standard HTML focusable/tabbable selectors (removes 1st-gen [focusable] attribute workaround) - Refactor hasVisibleFocusInTree() to use getActiveElement(), removing dead ancestor-chain code and legacy .focus-visible polyfill fallback - Export new utilities from utils/index.ts
Phase 2 of focus management migration (FOCUS-MANAGEMENT-PROPOSAL.md §5). Consolidates 1st-gen FocusGroupController + RovingTabindexController into a single, self-contained reactive controller. Key changes from 1st-gen: - Flat single-file architecture (no base class inheritance) - MutationObserver uses attributeFilter for disabled/aria-disabled only - Listener scope defaults to host.renderRoot for shadow DOM encapsulation - Removes redundant click handler (focusin covers click-to-focus) - Removes virtualization offset (deferred until needed) - Simplified tabindex management (direct tabIndex set, no requestUpdate)
Phase 3 of focus management migration (FOCUS-MANAGEMENT-PROPOSAL.md §5). Extracts disabled state handling from 1st-gen Focusable base class into a standalone, composable mixin. Uses aria-disabled on the host for screen reader discoverability rather than the native disabled attribute. Manages tabindex removal/restoration and blurs active element on disable.
|
📚 Branch Preview Links🔍 First Generation Visual Regression Test ResultsWhen a visual regression test fails (or has previously failed while working on this branch), its results can be found in the following URLs:
Deployed to Azure Blob Storage: If the changes are expected, update the |
* feat(controllers): focusgroup navigation controller proof of concept * fix: accountsr shadowDOM children * fix(controllers): fixed focusgroup navigation keyboard events and disabled items * docs(core): updated storybook docs for core * feat: added row navigation for grids * feat(core): added option for both sets of arrow keys to navigate * feat(core): added page up/down support * test(core): added controllet tests * feat(core): skip disabled items feature * docs: added notes for when focusgroup has native support * feat(core): printable character navigation support * chore(core): improved folder structure * fix(core): revised method for setting active item * fix(core): fixes broken paths from renaming * test(core): fixed focusgroup stories
There was a problem hiding this comment.
I have two blocking concerns before technical review:
-
No consuming component validates composition. DisabledMixin, delegatesFocus, and FocusgroupNavigationController are designed to work together, but no 2nd-gen component uses them yet. Before merging infrastructure, I need to see at least one component (e.g., a simple toolbar or action-button) that exercises all three primitives together to prove they compose correctly.
-
DisabledMixin is not a replacement for Focusable. The 1st-gen Focusable class provides
focus(),blur(),click()overrides,autofocus,focusElementdelegation,selfManageFocusElement, and manipulatingTabindex.DisabledMixinonly handlesaria-disabled,tabindexsave/restore, and blur-on-disable. The migration cost for components moving from Focusable is significant and the documentation does not surface this adequately.
Additional concerns for discussion:
FocusgroupNavigationController is missing APIs that 1st-gen composite widgets depend on: elementEnterAction (Tabs, Accordion), stopKeyEventPropagation (nested composites), manage/unmanage (Menu search mode), focusOnItem, reset. If these are intentionally deferred, please document what's deferred vs. what's deliberately dropped.
Holding off on line-by-line technical review until the above are addressed.
Co-authored-by: Rajdeep Chandra <rajrock38@gmail.com>
| @@ -0,0 +1,1109 @@ | |||
| /** | |||
There was a problem hiding this comment.
This is deliberately lightweight, as we can decide which features should be added to this controller, to a new controller, or to the component itself as components demonstrate need.
Note that many components that use focus group involve a11y changes and will be set to DelegatesFocus: true so that some of the old may not need what they used to from the old controller.
@Rajdeepc see my comments |
|
- CEM analyzer was including demo host classes and test files from ../core/ relative paths that bypassed the *.stories.ts and *.test.ts exclude patterns - add **/stories/** and **/test/** with explicit ../core/ variants to cem.config.js exclude - add **/stories/** to core tsconfig.json exclude so demo hosts are never compiled to dist/
- move 7 existing demo host classes from the stories file to stories/demo-hosts.ts so they can be imported without polluting CSF exports - add configurable DemoFocusgroupPlayground host that mirrors FocusgroupNavigationOptions as reactive properties for Storybook controls - remove excludeStories from meta since demo hosts are no longer co-located
|
@Rajdeepc I have migrated the updated RFC to the PR in project planning so the slack canvas is now out of date and i will clean that up in my tomorrow.
I think to honor the scope of this RFC proposal, this work would be a follow up in the next sprint, im thinking Tabs since it would need all three. To incorporate an entire component migration with this is out of scope and we should approach this agilely and iterate as we migrate more components. Accepting this with the agnostic component setups in the documentation covers the testing concern to be accepted in to main. This will also unblock several other components that didnt need all the complexity of 1st-gen now that we have separated the concerns. I have addressed the three other concerns in the documentation and RFC so those should now be covered! Let me know if i missed anything else. :) |
There was a problem hiding this comment.
All of the storybook docs changes were to allow the controller docs to work correctly. this will be re-evaluated in the docs IA epic for scalability but can be accepted with little to no risk.
Fair point. I think you have addressed the documentation gaps and would really appreciate the Tabs follow-up being tracked as a ticket so it doesn't slip between sprints. |
There was a problem hiding this comment.
Nice effort. I have few suggestions in some areas where we can improve the cyclometric complexity as well as how we reduce DOM burnouts. I did an initial pass and please find my feedback. Open to discuss if you need any guidance or you feel it can be done in an alternate way.
Blocking for me would be
- I see a conflict in DisabledMixin + FocusGroupNavigationController tabindex. Would love to see a new test and potentially a coordination mechanism.
- Please add a test for memory behaviour and RTL behaviour, dynamic item addition/removal, onActiveItemChange callback, setOptions(), for inert attribute handling, disconnection/reconnection
- Add a migration path for
elementEnterAction. We are now usingonActiveItemChange
...es/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts
Outdated
Show resolved
Hide resolved
...es/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts
Show resolved
Hide resolved
...es/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts
Show resolved
Hide resolved
...es/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts
Outdated
Show resolved
Hide resolved
...es/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts
Outdated
Show resolved
Hide resolved
...es/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts
Show resolved
Hide resolved
...es/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts
Outdated
Show resolved
Hide resolved
...es/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts
Show resolved
Hide resolved
| // duplicate or diverge from native axis mapping — revisit when testing RTL with native focusgroup. | ||
| // ───────────────────────────────────────────────────────────────────────────── | ||
|
|
||
| export class FocusgroupNavigationController implements ReactiveController { |
There was a problem hiding this comment.
Suggestion (non-blocking): This is a pretty large comment for the class. I wonder if it warrants a readme or some sort of supporting doc, instead of just JSDoc.
There was a problem hiding this comment.
ill be looking in to the docs pattern for controllers and other types of content in the docs IA epic and will note this concern, i am in agreement, its not great.
…Controller Add 7 new interaction tests and 2 supporting demo hosts to cover gaps identified during staff-level review of the focus management primitives: - Memory tab re-entry (#25): verifies tabindex=0 persists on last-focused item after focus leaves and re-enters the group - RTL arrow navigation (#26): verifies ArrowLeft/ArrowRight swap in dir="rtl" context with wrap - Dynamic item refresh (#27): verifies refresh() correctly reassigns roving tabindex after reactive item list changes - Active-change event and callback (#28, #29): verifies onActiveItemChange callback and swc-focusgroup-navigation-active-change custom event fire with correct detail, bubble, and compose - setOptions direction change (#30): verifies switching direction from horizontal to both at runtime enables ArrowDown navigation - Inert attribute handling (#31): verifies items with [inert] are excluded from navigation and become navigable again after removal - Disconnect/reconnect (#32): verifies arrow navigation resumes correctly after host is removed from and re-appended to the DOM Also fixes test file imports: story objects are now imported from the stories module (where they are defined) rather than demo-hosts. Made-with: Cursor
...es/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts
Show resolved
Hide resolved
...es/core/controllers/focusgroup-navigation-controller/src/focusgroup-navigation-controller.ts
Outdated
Show resolved
Hide resolved
@rajdeep is contributing to this PR and addressing the blocking issues he had. We are dismissing this review status so that other team mates can review and approve now that he is a co-author!! Thanks for the support on this Rajdeep!!
5t3ph
left a comment
There was a problem hiding this comment.
Suggestion: prevent tabindex="0" from being set on a disabled button and instead skip it since otherwise the other group items are modified to tabindex="-1" and the entire group becomes unnavigable via keyboard.
@miwha-adobe great question, so theres some nuance around disabled attribute effectively makes it hidden and untabble/identifiable by screen readers and keyboard. I think Steph has highlighted an issue with the breaking of keyboard navigation and perhaps making skipDisabled the default so you can continue with keyboard and opt in to the block. e.g. walking a user through a stepper where steps need to be completed before you can reach the next step and dont want a user to skip it Hope this helps, im still wrapping my brain around the nuance too but the article i think explains it much better than me! :D https://kittygiraudel.com/2024/03/29/on-disabled-and-aria-disabled-attributes/ |
From the APG
Since componenents using focus group / RTI nav are considered "composite widgets", I leaned toward making disabled items focusable as the default. Or perhaps we should skip |
I think this is the correct mental model we should adopt. |
|
@5t3ph Great catch, Stephanie. This is a real accessibility issue — natively We should update
I have also added a dedicated test ( |
- isRtl(): replace attribute-walking with getComputedStyle so CSS-inherited direction (2nd-gen default) is correctly detected - Home/End in grid mode now scopes to the current row per the WAI-ARIA APG grid pattern; Ctrl+Home/End still covers the full grid - applyRovingTabindex: natively disabled elements are never chosen as the roving tab stop since they cannot receive focus, preventing the group from becoming unreachable via Tab Tests: - CSSInheritedRTL: verifies arrow swap with style="direction: rtl" (no dir attribute) - Updated grid Home/End expectations to verify row-scoped behavior - DisabledButtonNeverTabStop: verifies tabindex="0" skips natively disabled buttons and lands on the next eligible item Made-with: Cursor



Description
Implements the core focus management infrastructure for 2nd-gen Spectrum Web Components, as defined in the Focus Management Proposal. This PR delivers the foundational primitives that all component migrations will build on.
Core Utilities
get-active-element.ts— Shadow DOM-awareactiveElementtraversal that followsshadowRoot.activeElementchains to find the deepest focused elementfocusable-selectors.ts— Standard HTMLfocusableSelectorandtabbableSelectorstrings (removes 1st-gen[focusable]attribute workaround in favor of nativedelegatesFocus)hasVisibleFocusInTree()cleanup — Refactored from ~30 lines to 3 inspectrum-element.ts, removing dead ancestor-chain traversal code and legacy.focus-visiblepolyfill fallback. Now delegates to the newgetActiveElement()utility.Controller
FocusgroupNavigationController— Implements the rovingtabindexpattern from the WAI-ARIA APG and directional navigation aligned with the proposedfocusgroupHTML attribute (Open UI), with a polyfill until browsers ship native support.Key capabilities:
tabindex="0") per composite widgethorizontal,vertical,both, andgriddirection modespageStep(Page Up/Down navigation)focusFirstItemByTextPrefix()swc-focusgroup-navigation-active-changecustom event on active item changeshost.renderRootfor shadow DOM encapsulationSupersedes the 1st-gen
FocusGroupController+RovingTabindexControllersplit with a single, consolidated controller.Mixin
DisabledMixin— Standalone mixin extracted from 1st-genFocusablebase class. Adds a reactivedisabledproperty witharia-disabled, tabindex management, and blur-on-disable. Usesaria-disabledon the host so elements remain discoverable by assistive technology.Documentation & Testing
CONTRIBUTOR-DOCS/01_contributor-guides/13_focus-management.md) — Comprehensive focus management guide for contributors14_controller-composition.mdwith focus controller patternsFocusgroupNavigationControllermodes (horizontal, vertical, both, grid, wrap, memory, skipDisabled, pageStep, typeahead)FocusgroupNavigationControllerfocus-group-navigation-controller.mdwith API referenceFile Structure
Motivation and Context
The 1st-gen focus architecture uses a deep inheritance chain (
Focusablebase class) that forces all-or-nothing inheritance, carries dead polyfill code, and uses a fragile flag system. The 2nd-gen strategy replaces this with composable, opt-in primitives: nativedelegatesFocus: true,FocusgroupNavigationController, andDisabledMixin.Centralizing composite widget keyboard navigation on a single controller aligned with the
focusgroupproposal reduces duplicated roving-tabindex logic across components, improves consistency, and shrinks maintenance surface while preserving WAI-ARIA APG accessibility expectations.Related Issues
Manual Testing
Run Storybook from
2nd-gen/packages/swc.1. Horizontal Toolbar
2. Both Axes Linear
3. Vertical Menu
aria-disabled="true"but is still focusable (skipDisabled: false)pageStep: 2)4. Skip Disabled Menu
disabled) and "Close" (aria-disabled="true")5. Grid (3×3)
pageStep: 2)6. Programmatic Focus
7. Text Prefix (Typeahead)
8. General Checks
:focus-visibleoutline appears on keyboard navigation (not on click)tabindex="0"is on exactly one item in each group, all others aretabindex="-1"dir="rtl") — ArrowRight should go backward in horizontal mode