Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion GLOSSARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<fieldset disabled>` (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

Expand Down
25 changes: 14 additions & 11 deletions apps/docs/content/docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<fieldset disabled>` 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.
Expand Down Expand Up @@ -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 `<fieldset disabled>`.
- 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
Expand Down Expand Up @@ -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. |
Expand Down Expand Up @@ -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 `<fieldset disabled>`, 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:

Expand Down
3 changes: 2 additions & 1 deletion apps/docs/content/docs/examples/skipped-items.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<fieldset disabled>` disables,
since it matches `:disabled`:

```html
<button data-keyrove-item tabindex="0">Rename</button>
Expand Down
3 changes: 2 additions & 1 deletion apps/docs/content/docs/examples/typeahead.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<fieldset disabled>`, are
never matched.

## When the text is not the label

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/glossary.md
Original file line number Diff line number Diff line change
Expand Up @@ -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). |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
38 changes: 38 additions & 0 deletions packages/keyrove/src/__tests__/keyRove/keyRove.focusKey.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<fieldset disabled>`. */
const renderFieldset = (options?: Parameters<typeof keyRove>[1]) => {
const results: RoveResult[] = [];
const fieldset = document.createElement('fieldset');
fieldset.disabled = true;
fieldset.innerHTML = `<button id="b" ${KEYROVE_ATTR_ITEM} ${KEYROVE_ATTR_FOCUS_KEY}="KeyB">B</button>`;
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: '' })], {
Expand Down
68 changes: 68 additions & 0 deletions packages/keyrove/src/__tests__/keyRove/keyRove.navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,74 @@ describe('keyRove', () => {
from: null,
});
});

describe('disabled by an ancestor fieldset', () => {
/** A, then B inside `<fieldset disabled>`, then C. */
const renderFieldsetList = (
inner = '<button id="b" data-keyrove-item>B</button>',
) => {
const results: RoveResult[] = [];
const container = document.createElement('div');
container.innerHTML = `
<button id="a" data-keyrove-item>A</button>
<fieldset id="fieldset" disabled>${inner}</fieldset>
<button id="c" data-keyrove-item>C</button>
`;
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(
'<div id="b" data-keyrove-item tabindex="-1">B</div>',
);
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', () => {
Expand Down
5 changes: 2 additions & 3 deletions packages/keyrove/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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),
);
};

Expand Down
16 changes: 12 additions & 4 deletions packages/keyrove/src/group.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<fieldset
* disabled>` 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
Expand Down
10 changes: 6 additions & 4 deletions packages/keyrove/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<fieldset disabled>` does, are
* never navigable, whichever reading found them.
*/
items?: string | ReadItems;
/**
Expand Down Expand Up @@ -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
* `<fieldset disabled>` matches `:disabled` without carrying it. A nested
* root's items keep their place in it, and so do skipped ones.
*/
readonly items: Element[];
/**
Expand Down
Loading