Skip to content

fix: treat items disabled by a fieldset as disabled - #42

Merged
mixedrays merged 1 commit into
mainfrom
fix/fieldset-disabled-item
Nov 6, 2026
Merged

mixedrays merged 1 commit into
mainfrom
fix/fieldset-disabled-item

Conversation

@mixedrays

Copy link
Copy Markdown
Owner

Summary

A control inside <fieldset disabled> matches :disabled but doesn't carry the disabled attribute itself. keyrove checked only the attribute, so the control stayed in the item sequence. Each arrow move onto it was refused by focus() and returned a consumed no-op, so the items after it couldn't be reached by arrows. Typeahead also matched it, a focus key on it swallowed the key, and initRovingTabindex could give it the stop.

<button id="a" data-keyrove-item>A</button>
<fieldset disabled>
  <button id="b" data-keyrove-item>B</button>   <!-- b.matches(':disabled') === true -->
</fieldset>
<button id="c" data-keyrove-item>C</button>

The fix updates one predicate, which both readers of a disabled item now share:

 // packages/keyrove/src/group.ts
-const isDisabled = (item: Element) => item.hasAttribute('disabled');
+export const isDisabled = (element: Element) =>
+  element.hasAttribute('disabled') || element.matches(':disabled');
isDisabled
├── group.items           → item sequence, typeahead candidates
├── group.isNavigable
├── stop holder           → initRovingTabindex, land
└── readFocusKeys filter  (config.ts, was its own hasAttribute check)
  • The disabled attribute still counts on any element, such as <li disabled>, which :disabled doesn't match.
  • :disabled matches only form controls, so a focusable non-control such as <div tabindex="-1"> inside a disabled fieldset is still an item.
  • aria-disabled still doesn't count. A control with it can still take focus.
  • The docs now say "carrying disabled, or disabled by an ancestor <fieldset disabled>": GLOSSARY.md, docs/glossary.md, docs/api.md, examples/skipped-items.md, examples/typeahead.md and the types.ts doc comments. The api.md hunk is bigger than the wording change because Prettier re-padded its attribute table.

Evidence

  • Before: the new jsdom tests, run against main's group.ts and config.ts. Six fail. The seventh is a guard that passes either way.

    navigation   A → ArrowDown            expect focus on C     ✗ got A
    navigation   disable, then re-enable  expect B back         ✗
    focusKey     attribute on B           expect key untouched  ✗
    focusKey     focusKeys map names B    expect key untouched  ✗
    typeahead    type "b"                 expect null           ✗
    initRoving   B never gets the stop                          ✗
    navigation   <div tabindex=-1> in the fieldset stays an item  ✓ (guard)
    
    AssertionError: expected 'a' to be 'c'   keyRove.navigation.test.ts:299
    Test Files  4 failed (4)
         Tests  6 failed | 133 passed (139)
    

    After:

    pnpm --filter @mixedrays/keyrove test   Test Files 25 passed (25), Tests 561 passed (561)
    pnpm typecheck                          keyrove, docs, bench: Done
    pnpm lint                               All matched files use Prettier code style!
    

    The bench tests (19), docs tests (19) and browser suite (21) also pass.

  • Benchmark: pnpm bench:headless, two runs each before and after. M1 Pro, Chrome 156 headless, medians.

    Case Change
    All keyRove cases, geometric mean +4.7%
    Unbound keys ~0%
    Bound no-move press, 20-item group +0.5 to 0.9 µs
    Moves that call focus() +1 to 3%
    1000-focus-key document scans (400 to 730 µs) +40 to 60 µs
    "1000 on page, local listener" 2.9 → 3.9 µs (one more :disabled check per press)

    The cost is one matches(':disabled') per item, about 30 to 50 ns each. We judged it not meaningful in absolute terms. We found no cheaper check that is still correct: 'disabled' in el misses form-associated custom elements, and closest('fieldset[disabled]') mishandles the fieldset's first <legend>. To get these numbers, we had to patch the bench runner locally (uncommitted) around an ENOTEMPTY error when it removes Chrome's profile. That bug is tracked separately.

Merge Danger

Door: two-way

The change is one predicate and has no stored state, so a revert undoes it. Merging into main triggers release-it, which tags a patch release from this fix: commit. A revert would need a follow-up release.

Blast Radius: behavioural

  • Apps that put keyrove items inside a <fieldset disabled> see those items leave the item sequence. Arrows, Home/End, typeahead and focus keys now skip them. This matches what the README already promised.
  • In a grid, removing an item shifts the cells after it, as the disabled attribute already does.
  • Each bound keypress does a little more work (see Benchmark). Unbound keys cost the same as before.
  • Not in this PR: continuing a move past an item that refuses focus for some other reason. That is tracked separately.

🤖 Generated with Claude Code

keyrove read "disabled" only from an element's own `disabled` attribute,
so a button inside `<fieldset disabled>` stayed in the item sequence.
Every arrow move onto it was refused by focus() and became a consumed
no-op, so the items after it could not be reached, typeahead matched it,
a focus key declared on it swallowed the key, and initRovingTabindex
could give it the stop.

An element is now disabled when it carries `disabled` or matches
`:disabled`. The item sequence, the navigable test, the stop holder and
the focus-key filter all use the same check. `aria-disabled` still does
not count. A focusable non-control inside a disabled fieldset stays an
item, since `:disabled` matches only form controls.
@mixedrays
mixedrays marked this pull request as ready for review November 6, 2026 14:35
@mixedrays
mixedrays merged commit 5781abf into main Nov 6, 2026
1 check passed
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