diff --git a/GLOSSARY.md b/GLOSSARY.md
index 30fcabe..181c518 100644
--- a/GLOSSARY.md
+++ b/GLOSSARY.md
@@ -44,7 +44,7 @@ An item that keeps its place in the item sequence but is not navigable. Skipping
_Avoid_: Disabled item, hidden item, removed item
**Disabled item**:
-An item carrying the `disabled` attribute, which removes it from the item sequence and from focus-key destinations.
+An item carrying the `disabled` attribute, or disabled by an ancestor `
` (it matches `:disabled`), which removes it from the item sequence and from focus-key destinations.
In keyrove, `aria-disabled` alone does not make a disabled item.
_Avoid_: Skipped item, inactive item, unavailable item
diff --git a/apps/docs/content/docs/api.md b/apps/docs/content/docs/api.md
index ced8e10..b89587a 100644
--- a/apps/docs/content/docs/api.md
+++ b/apps/docs/content/docs/api.md
@@ -201,8 +201,10 @@ direction and `cols: 'auto'` are read from the host element.
so changing one takes effect at once; see
[responsive grid](/docs/examples/responsive-grid).
- Items are all enabled `data-keyrove-item` elements under the root, in DOM
- order, except those carrying `disabled`. This includes items in nested
- roots. Use sibling roots to keep attribute-selected sequences separate.
+ order, except disabled ones: those carrying `disabled` or matching
+ `:disabled`, as a control inside a `` does. This
+ includes items in nested roots. Use sibling roots to keep
+ attribute-selected sequences separate.
With [roving tabindex](/docs/examples/roving-tabindex), each root keeps its
own tab stop: a move onto a nested root's item moves that root's stop and
leaves the outer group's in place.
@@ -320,7 +322,8 @@ editable targets. The reported action is `'focus'`.
- Focus keys sit first in the [binding table](#precedence), so they win any
collision with the root's bindings or the defaults. Two elements naming one
combo resolve to the first in DOM order. The attribute scan excludes targets
- with an enabled `data-keyrove-skip` or a `disabled` attribute.
+ with an enabled `data-keyrove-skip` or a `disabled` attribute, and those
+ matching `:disabled`, such as a control inside a ``.
- The element need not be an item. An item stays in its group's order, so the
arrows reach it too; any other element is reached by its key alone.
- On an item, the move happens in the item's own group: the nearest root above
@@ -364,7 +367,7 @@ keyRove(e, { items: '[role="menuitem"]' }); // override item lookup only
| Option | Type | Falls back to | Meaning |
| ---------------- | ------------------------------------------------------------------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `items` | `string \| (root: Element) => Element[]` | `data-keyrove-item` | The group's items: a selector run inside the root, or a function returning them. Elements carrying `disabled` are never navigable. |
+| `items` | `string \| (root: Element) => Element[]` | `data-keyrove-item` | The group's items: a selector run inside the root, or a function returning them. Elements carrying `disabled` or matching `:disabled` are never navigable. |
| `root` | `string` | `data-keyrove-root` | Selector a [root](#roots) answers to, matched at or above the event's target. |
| `cols` | `number \| 'auto'` | `data-keyrove-cols` | Columns. Above 1 the group is a grid. `'auto'` counts the root's CSS grid tracks on every keypress. |
| `loop` | `boolean` | `data-keyrove-loop` | Whether next/prev wrap at the ends. Lists only. |
@@ -748,13 +751,13 @@ explains scope and replacement exceptions.
On an item:
-| Attribute | Meaning |
-| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
-| `data-keyrove-item` | Marks an element as navigable. It is the position whenever focus is anywhere inside it. |
-| `data-keyrove-skip` | Passed over when moving; keeps its place in the sequence. `disabled` also excludes an element, but removes it from the sequence entirely, so in a grid it shifts the cells after it. |
-| `data-keyrove-roving-tabindex` | Moves the `tabindex="0"` tab stop along with focus. |
-| `data-keyrove-focus-key` | [Combo](#combos) that focuses this element from anywhere under the listener, e.g. `ctrl+shift+KeyE`. Works without `data-keyrove-item`, too. |
-| `data-keyrove-typeahead` | Label for [type-to-focus](#createtypeahead-options), when the item's own text is not it. |
+| Attribute | Meaning |
+| ------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
+| `data-keyrove-item` | Marks an element as navigable. It is the position whenever focus is anywhere inside it. |
+| `data-keyrove-skip` | Passed over when moving; keeps its place in the sequence. A disabled element, carrying `disabled` or inside a ``, is excluded too, but leaves the sequence entirely, so in a grid it shifts the cells after it. |
+| `data-keyrove-roving-tabindex` | Moves the `tabindex="0"` tab stop along with focus. |
+| `data-keyrove-focus-key` | [Combo](#combos) that focuses this element from anywhere under the listener, e.g. `ctrl+shift+KeyE`. Works without `data-keyrove-item`, too. |
+| `data-keyrove-typeahead` | Label for [type-to-focus](#createtypeahead-options), when the item's own text is not it. |
On the root, read on every keypress:
diff --git a/apps/docs/content/docs/examples/skipped-items.md b/apps/docs/content/docs/examples/skipped-items.md
index ce51f5c..b52b8ab 100644
--- a/apps/docs/content/docs/examples/skipped-items.md
+++ b/apps/docs/content/docs/examples/skipped-items.md
@@ -24,7 +24,8 @@ see [configuration differences](/docs/attributes-and-options#configuration-diffe
## Disabled elements
Anything carrying the `disabled` attribute is excluded automatically, with no
-`data-keyrove-skip` needed:
+`data-keyrove-skip` needed. So is a control a `` disables,
+since it matches `:disabled`:
```html
Rename
diff --git a/apps/docs/content/docs/examples/typeahead.md b/apps/docs/content/docs/examples/typeahead.md
index 2e610b0..f0d01a7 100644
--- a/apps/docs/content/docs/examples/typeahead.md
+++ b/apps/docs/content/docs/examples/typeahead.md
@@ -80,7 +80,8 @@ else:
Matching is by prefix. A character that matches nothing is left to the
browser but still joins the buffer, so a mistyped prefix goes quiet until the
reset clears it rather than jumping somewhere unexpected. Items carrying
-`data-keyrove-skip` or `disabled` are never matched.
+`data-keyrove-skip` or `disabled`, or disabled by a ``, are
+never matched.
## When the text is not the label
diff --git a/apps/docs/content/docs/glossary.md b/apps/docs/content/docs/glossary.md
index 4c32e23..d7943c2 100644
--- a/apps/docs/content/docs/glossary.md
+++ b/apps/docs/content/docs/glossary.md
@@ -17,7 +17,7 @@ order: 4.5
| Focus key | A combo that focuses one element from anywhere under the listener, across roots, set with `data-keyrove-focus-key` or the `focusKeys` option. The element need not be an item. See [focus keys](/docs/examples/focus-keys). |
| Grid | A group whose column count, `cols`, is above 1. Its items fill rows of that many cells in DOM order, and the arrows move by cell and by row. See [grid](/docs/examples/grid). |
| Group | The items one root governs, together with the settings read off that root or passed as options. A group is a list, or a grid when it has columns. See [how it works](/docs/introduction#how-it-works). |
-| Item | An element keyrove can move focus to: one marked `data-keyrove-item`, or one the `items` option selects. Elements carrying `disabled` are never navigable. See [how it works](/docs/introduction#how-it-works). |
+| Item | An element keyrove can move focus to: one marked `data-keyrove-item`, or one the `items` option selects. Elements carrying `disabled` or matching `:disabled` are never navigable. See [how it works](/docs/introduction#how-it-works). |
| Listener | The element the keydown handler is attached to, the event's `currentTarget`. It stands in as the root when no marked root is found, and bounds how far focus keys and exit reach. See [roots](/docs/api#roots). |
| Looping | Next and prev wrapping from the last item to the first and back, set with `data-keyrove-loop` or the `loop` option. Lists only; grids keep their edges. See [looping lists](/docs/examples/looping-lists). |
| Navigable item | An item a move can land on: one that is neither skipped nor disabled. Entering a group, the ends of a list and page jumps all count navigable items only. See [edges and looping](/docs/api#edges-and-looping). |
diff --git a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts
index e4952e0..0635d59 100644
--- a/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts
+++ b/packages/keyrove/src/__tests__/createTypeahead/createTypeahead.test.ts
@@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, vi } from 'vitest';
import { createTypeahead } from '../../createTypeahead';
import {
KEYROVE_ATTR_FOCUS_KEY,
+ KEYROVE_ATTR_ITEM,
KEYROVE_ATTR_NEXT_KEY,
KEYROVE_ATTR_ROOT,
} from '../../keyRove';
@@ -651,6 +652,32 @@ describe('createTypeahead', () => {
expect(activeId()).toBe('b');
});
+
+ it('matches nothing in a control a disabled fieldset disables, and leaves the key untouched', () => {
+ const button = (id: string, label: string) => {
+ const el = document.createElement('button');
+ el.id = id;
+ el.textContent = label;
+ el.setAttribute(KEYROVE_ATTR_ITEM, '');
+
+ return el;
+ };
+ const fieldset = document.createElement('fieldset');
+ fieldset.disabled = true;
+ fieldset.appendChild(button('b', 'Banana'));
+ const { results } = renderList([
+ button('a', 'Apple'),
+ fieldset,
+ button('c', 'Cherry'),
+ ]);
+ document.getElementById('a')!.focus();
+
+ const event = pressKey('b');
+
+ expect(results[results.length - 1]).toBeNull();
+ expect(event.defaultPrevented).toBe(false);
+ expect(activeId()).toBe('a');
+ });
});
describe('roving tabindex', () => {
diff --git a/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts b/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts
index 10a3f0a..93cb203 100644
--- a/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts
+++ b/packages/keyrove/src/__tests__/initRovingTabindex/initRovingTabindex.test.ts
@@ -154,6 +154,21 @@ describe('initRovingTabindex', () => {
});
});
+ it('never gives the stop to an item a disabled fieldset disables', () => {
+ const fieldset = document.createElement('fieldset');
+ fieldset.disabled = true;
+ fieldset.append(item('holder', { tabindex: '0' }), item('fresh'));
+ const root = group(fieldset, item('a'), item('b'));
+
+ expect(initRovingTabindex(root)).toBe(document.getElementById('a'));
+ expect(tabindexes('holder', 'fresh', 'a', 'b')).toEqual({
+ holder: '-1',
+ fresh: '-1',
+ a: '0',
+ b: '-1',
+ });
+ });
+
it('collapses several stops to the first navigable one of them', () => {
const root = group(
item('a'),
diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts
index 033f7e3..2e72dc9 100644
--- a/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts
+++ b/packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts
@@ -261,6 +261,44 @@ describe('keyRove', () => {
expect(disabled.defaultPrevented).toBe(false);
});
+ describe('on a control a disabled fieldset disables', () => {
+ /** Item A, then button B with its own key inside ``. */
+ const renderFieldset = (options?: Parameters[1]) => {
+ const results: RoveResult[] = [];
+ const fieldset = document.createElement('fieldset');
+ fieldset.disabled = true;
+ fieldset.innerHTML = `B `;
+ renderList([createItem('a'), fieldset], {
+ options,
+ onResult: (result) => results.push(result),
+ });
+
+ return results;
+ };
+
+ it('leaves the key untouched when the attribute declares it', () => {
+ const results = renderFieldset();
+ byId('a').focus();
+
+ const event = pressKey('KeyB');
+
+ expect(activeId()).toBe('a');
+ expect(event.defaultPrevented).toBe(false);
+ expect(results).toEqual([null]);
+ });
+
+ it('leaves the key untouched when the focusKeys map names it', () => {
+ const results = renderFieldset({ focusKeys: { KeyB: '#b' } });
+ byId('a').focus();
+
+ const event = pressKey('KeyB');
+
+ expect(activeId()).toBe('a');
+ expect(event.defaultPrevented).toBe(false);
+ expect(results).toEqual([null]);
+ });
+ });
+
it('ignores a bare attribute without a combo', () => {
const results: RoveResult[] = [];
renderList([createItem('a'), createItem('b', { focusKey: '' })], {
diff --git a/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts b/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts
index 6d67d49..fa1d0d3 100644
--- a/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts
+++ b/packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts
@@ -269,6 +269,74 @@ describe('keyRove', () => {
from: null,
});
});
+
+ describe('disabled by an ancestor fieldset', () => {
+ /** A, then B inside ``, then C. */
+ const renderFieldsetList = (
+ inner = 'B ',
+ ) => {
+ const results: RoveResult[] = [];
+ const container = document.createElement('div');
+ container.innerHTML = `
+ A
+ ${inner}
+ C
+ `;
+ document.body.appendChild(container);
+ container.addEventListener('keydown', (e) => {
+ results.push(keyRove(e));
+ });
+
+ return results;
+ };
+
+ it('passes over a control the fieldset disables, both ways', () => {
+ const results = renderFieldsetList();
+ document.getElementById('a')!.focus();
+
+ pressKey('ArrowDown');
+
+ expect(activeId()).toBe('c');
+ expect(results[results.length - 1]).toMatchObject({
+ action: 'next',
+ from: document.getElementById('a'),
+ to: document.getElementById('c'),
+ });
+
+ pressKey('ArrowUp');
+
+ expect(activeId()).toBe('a');
+ expect(results[results.length - 1]).toMatchObject({
+ action: 'prev',
+ from: document.getElementById('c'),
+ to: document.getElementById('a'),
+ });
+ });
+
+ it('keeps a focusable non-control inside the fieldset as an item', () => {
+ renderFieldsetList(
+ 'B
',
+ );
+ document.getElementById('a')!.focus();
+
+ pressKey('ArrowDown');
+
+ expect(activeId()).toBe('b');
+ });
+
+ it('puts the control back in the sequence once the fieldset is enabled', () => {
+ renderFieldsetList();
+ document.getElementById('a')!.focus();
+
+ pressKey('ArrowDown');
+ expect(activeId()).toBe('c');
+
+ document.getElementById('fieldset')!.removeAttribute('disabled');
+ pressKey('ArrowUp');
+
+ expect(activeId()).toBe('b');
+ });
+ });
});
describe('position within an item', () => {
diff --git a/packages/keyrove/src/config.ts b/packages/keyrove/src/config.ts
index bb9828f..9105b39 100644
--- a/packages/keyrove/src/config.ts
+++ b/packages/keyrove/src/config.ts
@@ -22,7 +22,7 @@ import {
KEYROVE_ATTR_ORIENTATION,
KEYROVE_ATTR_PAGE_LENGTH,
} from './attributes.js';
-import { attributeSkip } from './group.js';
+import { attributeSkip, isDisabled } from './group.js';
import { keyAttribute } from './keyAttribute.js';
import {
hasEnabledAttribute,
@@ -204,8 +204,7 @@ export const readFocusKeys = (
}));
return declared.filter(
- (key): key is FocusKey =>
- !!key.target && !key.target.hasAttribute('disabled'),
+ (key): key is FocusKey => !!key.target && !isDisabled(key.target),
);
};
diff --git a/packages/keyrove/src/group.ts b/packages/keyrove/src/group.ts
index fb5895f..a2aa004 100644
--- a/packages/keyrove/src/group.ts
+++ b/packages/keyrove/src/group.ts
@@ -109,10 +109,18 @@ export const holdsFocus = (element: Element): boolean => {
return !!active && element.contains(active);
};
-// `disabled` is the DOM's own word for an element that takes no part, and it
-// means the same whichever source named the items: such an item is in no
-// group's sequence, and never holds a stop.
-const isDisabled = (item: Element) => item.hasAttribute('disabled');
+/**
+ * Whether an element takes no part: it carries `disabled`, the DOM's own word
+ * for that, or matches `:disabled`, as a control inside a `` does without carrying the attribute itself. The attribute counts
+ * on any element, though `:disabled` matches only form controls. It means the
+ * same whichever source named the items: such an item is in no group's
+ * sequence and never holds a stop, and such an element is no focus key's
+ * destination. `aria-disabled` is no part of it: an element carrying it still
+ * takes focus.
+ */
+export const isDisabled = (element: Element) =>
+ element.hasAttribute('disabled') || element.matches(':disabled');
/**
* Gives `stop` the group's one `tabindex="0"` and every other of its roving
diff --git a/packages/keyrove/src/types.ts b/packages/keyrove/src/types.ts
index f4c650f..ab3a68c 100644
--- a/packages/keyrove/src/types.ts
+++ b/packages/keyrove/src/types.ts
@@ -154,8 +154,9 @@ export type Move = MoveResult & { to: Element };
export type GroupOptions = {
/**
* The group's items: a selector run inside the root, or a reading of your
- * own. Defaults to the item attribute. Items carrying `disabled` are never
- * navigable, whichever reading found them.
+ * own. Defaults to the item attribute. Disabled items, carrying `disabled`
+ * or matching `:disabled` as a control in a `` does, are
+ * never navigable, whichever reading found them.
*/
items?: string | ReadItems;
/**
@@ -463,8 +464,9 @@ export type Group = {
/**
* The sequence a move strides through: the root's items in DOM order,
* disabled ones aside — `disabled` is the DOM's own word for an element that
- * takes no part, whichever source named the items. A nested root's items
- * keep their place in it, and so do skipped ones.
+ * takes no part, whichever source named the items, and a control inside a
+ * `` matches `:disabled` without carrying it. A nested
+ * root's items keep their place in it, and so do skipped ones.
*/
readonly items: Element[];
/**