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 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 = ``; + 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 = '', + ) => { + const results: RoveResult[] = []; + const container = document.createElement('div'); + container.innerHTML = ` + +
${inner}
+ + `; + 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[]; /**