Skip to content

feat(core): Add focus management primitives for 2nd-gen architecture#6129

Open
caseyisonit wants to merge 25 commits intomainfrom
caseyisonit/focus-management
Open

feat(core): Add focus management primitives for 2nd-gen architecture#6129
caseyisonit wants to merge 25 commits intomainfrom
caseyisonit/focus-management

Conversation

@caseyisonit
Copy link
Copy Markdown
Contributor

@caseyisonit caseyisonit commented Apr 1, 2026

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-aware activeElement traversal that follows shadowRoot.activeElement chains to find the deepest focused element
  • focusable-selectors.ts — Standard HTML focusableSelector and tabbableSelector strings (removes 1st-gen [focusable] attribute workaround in favor of native delegatesFocus)
  • hasVisibleFocusInTree() cleanup — Refactored from ~30 lines to 3 in spectrum-element.ts, removing dead ancestor-chain traversal code and legacy .focus-visible polyfill fallback. Now delegates to the new getActiveElement() utility.

Controller

  • FocusgroupNavigationController — Implements the roving tabindex pattern from the WAI-ARIA APG and directional navigation aligned with the proposed focusgroup HTML attribute (Open UI), with a polyfill until browsers ship native support.

    Key capabilities:

    • Keeps exactly one item in the tab order (tabindex="0") per composite widget
    • Supports horizontal, vertical, both, and grid direction modes
    • Optional wrap, last-focused memory, skip-disabled, and pageStep (Page Up/Down navigation)
    • Grid mode uses bounding-rect layout for row/column navigation with Ctrl+Home/Ctrl+End support
    • Printable character typeahead via focusFirstItemByTextPrefix()
    • Dispatches a bubbling, composed swc-focusgroup-navigation-active-change custom event on active item changes
    • Flat single-file architecture (no base class inheritance from 1st-gen)
    • Listener scope defaults to host.renderRoot for shadow DOM encapsulation

    Supersedes the 1st-gen FocusGroupController + RovingTabindexController split with a single, consolidated controller.

Mixin

  • DisabledMixin — Standalone mixin extracted from 1st-gen Focusable base class. Adds a reactive disabled property with aria-disabled, tabindex management, and blur-on-disable. Uses aria-disabled on the host so elements remain discoverable by assistive technology.

Documentation & Testing

  • Contributor guide (CONTRIBUTOR-DOCS/01_contributor-guides/13_focus-management.md) — Comprehensive focus management guide for contributors
  • Controller composition style guide — Updated 14_controller-composition.md with focus controller patterns
  • Storybook stories — Interactive demos for all FocusgroupNavigationController modes (horizontal, vertical, both, grid, wrap, memory, skipDisabled, pageStep, typeahead)
  • Unit tests — Test suite for FocusgroupNavigationController
  • Controller documentationfocus-group-navigation-controller.md with API reference
  • Storybook config — Added storybook configuration for 2nd-gen controller stories

File Structure

2nd-gen/packages/core/
├── controllers/
│   ├── focus-group-navigation-controller.ts              NEW (re-export entry)
│   ├── focus-group-navigation-controller/
│   │   ├── src/focus-group-navigation-controller.ts      NEW (implementation)
│   │   ├── stories/focus-group-navigation-controller.stories.ts  NEW
│   │   ├── test/focus-group-navigation-controller.test.ts        NEW
│   │   └── focus-group-navigation-controller.md          NEW
│   └── index.ts                                          UPDATED
├── mixins/
│   ├── disabled-mixin.ts                                 NEW
│   └── index.ts                                          UPDATED
├── utils/
│   ├── get-active-element.ts                             NEW
│   ├── focusable-selectors.ts                            NEW
│   └── index.ts                                          UPDATED
├── element/
│   └── spectrum-element.ts                               UPDATED (hasVisibleFocusInTree cleanup)
├── overview.mdx                                          UPDATED
└── package.json                                          UPDATED

CONTRIBUTOR-DOCS/
├── 01_contributor-guides/13_focus-management.md           NEW
└── 02_style-guide/02_typescript/14_controller-composition.md  UPDATED

Motivation and Context

The 1st-gen focus architecture uses a deep inheritance chain (Focusable base 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: native delegatesFocus: true, FocusgroupNavigationController, and DisabledMixin.

Centralizing composite widget keyboard navigation on a single controller aligned with the focusgroup proposal reduces duplicated roving-tabindex logic across components, improves consistency, and shrinks maintenance surface while preserving WAI-ARIA APG accessibility expectations.

Related Issues

  • fixes SWC-1676

Manual Testing

Run Storybook from 2nd-gen/packages/swc.

1. Horizontal Toolbar

  • Open the "Focus group navigation controller / Horizontal Toolbar" story
  • Tab into the toolbar — only one button receives focus (roving tabindex)
  • ArrowRight / ArrowLeft move focus between Bold, Italic, Underline, Strikethrough
  • Focus wraps from Strikethrough → Bold and vice versa
  • Home jumps to Bold, End jumps to Strikethrough
  • Tab away and back — the last-focused button is remembered

2. Both Axes Linear

  • Open "Both Axes Linear" story
  • ArrowRight and ArrowDown both advance forward through items
  • ArrowLeft and ArrowUp both go backward
  • Wrapping is enabled in both directions

3. Vertical Menu

  • Open "Vertical Menu" story
  • ArrowDown / ArrowUp move through Copy, Paste, Cut (unavailable), Select all
  • "Cut (unavailable)" has aria-disabled="true" but is still focusable (skipDisabled: false)
  • Clicking or pressing Enter/Space on "Cut (unavailable)" does nothing
  • Page Down / Page Up skip 2 items at a time (pageStep: 2)

4. Skip Disabled Menu

  • Open "Skip Disabled Menu" story
  • ArrowDown skips "Save" (native disabled) and "Close" (aria-disabled="true")
  • Only New, Open, Print, Help are reachable via arrow keys
  • Wrap is enabled — Help → New and New → Help

5. Grid (3×3)

  • Open "Grid" story
  • ArrowRight / ArrowLeft move within a row
  • ArrowDown / ArrowUp move between rows (same column)
  • Home / End jump to first/last cell in row-major order
  • Ctrl+Home / Ctrl+End jump to cell 1 / cell 9
  • Page Down / Page Up move 2 rows at a time (pageStep: 2)
  • No wrapping — arrows stop at edges

6. Programmatic Focus

  • Open "Programmatic Focus" story
  • Click "Focus item C programmatically" — focus moves to Item C in the toolbar
  • Arrow keys work from the programmatically focused item

7. Text Prefix (Typeahead)

  • Open "Text Prefix Focus" story
  • Click 'Focus match for "Pas"' — focus jumps to Paste in the menu
  • Click 'Focus match for "cu"' — focus jumps to Cut (case-insensitive)

8. General Checks

  • :focus-visible outline appears on keyboard navigation (not on click)
  • tabindex="0" is on exactly one item in each group, all others are tabindex="-1"
  • Test in RTL (dir="rtl") — ArrowRight should go backward in horizontal mode

caseyisonit and others added 5 commits April 1, 2026 15:42
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.
@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Apr 1, 2026

⚠️ No Changeset found

Latest commit: e2f6a9f

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@caseyisonit caseyisonit added Status:Ready for review PR ready for review or re-review. 2nd gen These issues or PRs map to our 2nd generation work to modernizing infrastructure. High priority PR review PR is a high priority and should be reviewed ASAP labels Apr 1, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Apr 1, 2026

📚 Branch Preview Links

🔍 First Generation Visual Regression Test Results

When 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: pr-6129

If the changes are expected, update the current_golden_images_cache hash in the circleci config to accept the new images. Instructions are included in that file.
If the changes are unexpected, you can investigate the cause of the differences and update the code accordingly.

Base automatically changed from caseyisonit/separate-concerns to main April 3, 2026 15:23
caseyisonit and others added 3 commits April 3, 2026 09:43
* 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
Copy link
Copy Markdown
Contributor

@Rajdeepc Rajdeepc left a comment

Choose a reason for hiding this comment

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

I have two blocking concerns before technical review:

  1. 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.

  2. DisabledMixin is not a replacement for Focusable. The 1st-gen Focusable class provides focus(), blur(), click() overrides, autofocus, focusElement delegation, selfManageFocusElement, and manipulating Tabindex. DisabledMixin only handles aria-disabled, tabindex save/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.

@@ -0,0 +1,1109 @@
/**
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@nikkimk
Copy link
Copy Markdown
Contributor

nikkimk commented Apr 6, 2026

I have two blocking concerns before technical review:

  1. 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.
  2. DisabledMixin is not a replacement for Focusable. The 1st-gen Focusable class provides focus(), blur(), click() overrides, autofocus, focusElement delegation, selfManageFocusElement, and manipulating Tabindex. DisabledMixin only handles aria-disabled, tabindex save/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.

@Rajdeepc see my comments FocusgroupNavigationController here: #6129 (comment)

@caseyisonit
Copy link
Copy Markdown
Contributor Author

caseyisonit commented Apr 6, 2026

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, focusElement delegation, selfManageFocusElement, and manipulating Tabindex. DisabledMixin only handles aria-disabled, tabindex save/restore, and blur-on-disable. The migration cost for components moving from Focusable is significant and the documentation does not surface this adequately.

  • delegatesFocus is an implementation detail and strategy approach and not something to be validated until a component comes over that would follow that guidance.
  • DisabledMixin is NOT a replacement of Focusable. it simply isolates an a11y property that should be opt in and not included in every instance of focus. Its removing the coupling and making it more flexible for usage.
  • Please reference the Slack canvas as all the information about 1st-gen considerations are included and called out, this is a PR that supports the RFC proposal that has been documented and shared out. we cant't link internaly docs to PR.
  • The documentation included is comprehensive of reasoning and refactor guidance, please review the whole PR (ive added even more details to be extra clear)

- 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
@caseyisonit
Copy link
Copy Markdown
Contributor Author

@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’m still a bit curious about isn’t testing delegatesFocus in isolation, but how all three primitives behave together in a real component scenario. Right now, the Storybook demos are great for exercising the controller on its own, but we don’t yet have an example that combines DisabledMixin + delegatesFocus: true + FocusgroupNavigationController on the same element the way a migrated component would. Even a small proof-of-concept (like a simple action-group or toolbar prototype) will be helpful in validating that there aren’t any subtle interaction issues between tabindex management, roving tabindex, and browser focus delegation.

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.
TLDR We will migrate Tabs in the next round of components once this is merged in

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. :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

@Rajdeepc
Copy link
Copy Markdown
Contributor

Rajdeepc commented Apr 8, 2026

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. TLDR We will migrate Tabs in the next round of components once this is merged in

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. :)

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.

Copy link
Copy Markdown
Contributor

@Rajdeepc Rajdeepc left a comment

Choose a reason for hiding this comment

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

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

  1. I see a conflict in DisabledMixin + FocusGroupNavigationController tabindex. Would love to see a new test and potentially a coordination mechanism.
  2. Please add a test for memory behaviour and RTL behaviour, dynamic item addition/removal, onActiveItemChange callback, setOptions(), for inert attribute handling, disconnection/reconnection
  3. Add a migration path for elementEnterAction. We are now using onActiveItemChange

// duplicate or diverge from native axis mapping — revisit when testing RTL with native focusgroup.
// ─────────────────────────────────────────────────────────────────────────────

export class FocusgroupNavigationController implements ReactiveController {
Copy link
Copy Markdown

@miwha-adobe miwha-adobe Apr 8, 2026

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

caseyisonit and others added 5 commits April 8, 2026 15:34
…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
@Rajdeepc Rajdeepc self-requested a review April 10, 2026 14:14
@caseyisonit caseyisonit dismissed Rajdeepc’s stale review April 10, 2026 17:23

@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
Copy link
Copy Markdown
Contributor

5t3ph commented Apr 13, 2026

In the Playground story, I would expect to be able to arrow through items linearly, regardless of disabled status. I'm not sure if it should still give focus to the disabled items, or just skip over them. But if you try arrow nav, you will currently get blocked due to the disabled "Underline". It looks like at some point tabindex="0" is trying to be set on the disabled button.
image

Edit: I now see the skipDisabled option, but maybe still worth some kind of change to the Playground, or maybe that should be default?

Copy link
Copy Markdown
Contributor

@5t3ph 5t3ph left a comment

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown

Question: When inspecting the dom, it shows me that strikethrough has aria-disabled, and disabled has the "disabled" attribute. I'm curious why strikethrough visually appears disabled when hovered over but only has the aria-attribute. Should it also have the disabled attribute, not just aria? It gets skipped when skipDisabled is true, but when set to false, only 'disabled' button is skipped.

Screenshot 2026-04-13 at 12 54 55 PM

@caseyisonit
Copy link
Copy Markdown
Contributor Author

caseyisonit commented Apr 13, 2026

Question: When inspecting the dom, it shows me that strikethrough has aria-disabled, and disabled has the "disabled" attribute. I'm curious why strikethrough visually appears disabled when hovered over but only has the aria-attribute. Should it also have the disabled attribute, not just aria? It gets skipped when skipDisabled is true, but when set to false, only 'disabled' button is skipped.

@miwha-adobe great question, so theres some nuance around aria-disabled and disabled attritbutes. Based on this article @5t3ph shared about the accessibility implications and having the ability to opt in to certain disability states, this story should be exemplifying the difference between the two. Aria-disable allows for tabbing to exist and for screen readers to inform a user of an action that is avaialble but disabled for some reason i.e. form isn't filled out entirely so submit button is aria disabled, the user should still know the button exists but isnt active.

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/

@nikkimk
Copy link
Copy Markdown
Contributor

nikkimk commented Apr 13, 2026

In the Playground story, I would expect to be able to arrow through items linearly, regardless of disabled status. I'm not sure if it should still give focus to the disabled items, or just skip over them. But if you try arrow nav, you will currently get blocked due to the disabled "Underline". It looks like at some point tabindex="0" is trying to be set on the disabled button. image

Edit: I now see the skipDisabled option, but maybe still worth some kind of change to the Playground, or maybe that should be default?

From the APG

By default, disabled HTML input elements are removed from the tab sequence. In most contexts, the normal expectation is that disabled interactive elements are not focusable. However, there are some contexts where it is common for disabled elements to be focusable, especially inside of composite widgets. For example, as demonstrated in the menu and menubar pattern, disabled items are focusable when navigating through a menu with the arrow keys.

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 disabled but not skip aria-disabled and recommend that composite widgets use aria-disabled and the we wouldn't need the skipDisabled parameter?

@caseyisonit
Copy link
Copy Markdown
Contributor Author

Or perhaps we should skip disabled but not skip aria-disabled and recommend that composite widgets use aria-disabled and the we wouldn't need the skipDisabled parameter?

I think this is the correct mental model we should adopt. disabled is always skipped and aria-disabled behaves the way its intended. does this have any effect on how we setup disabledMixin?

@Rajdeepc
Copy link
Copy Markdown
Contributor

Rajdeepc commented Apr 14, 2026

@5t3ph Great catch, Stephanie. This is a real accessibility issue — natively disabled elements can't receive focus regardless of their tabindex value, so if the controller assigns tabindex="0" to one and tabindex="-1" to everything else, the entire group becomes unreachable via the Tab key.

We should update applyRovingTabindex to detect natively disabled elements (via the disabled DOM property) and fall through to the next non-disabled eligible item for the roving tab stop. To be clear about the behavior:

  • When skipDisabled: false (the default), disabled items remain in the arrow navigation order — this is intentional for aria-disabled patterns where the item should still be focusable and discoverable
  • But only items that can actually receive browser focus are eligible to hold tabindex="0" (the tab entry point)

I have also added a dedicated test (DisabledButtonNeverTabStop) that natively disables the first button in a group and verifies the tabindex="0" tab stop moves to the next eligible item, preventing the keyboard trap.

- 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

2nd gen These issues or PRs map to our 2nd generation work to modernizing infrastructure. High priority PR review PR is a high priority and should be reviewed ASAP Status:Ready for re-review PR has had its feedback addressed and is once again ready for review. Status:Ready for review PR ready for review or re-review.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants