Skip to content

Commit

Permalink
Add new getTabIndex() export (#1039)
Browse files Browse the repository at this point in the history
This is needed for focus-trap/focus-trap#974
which will add support for positive tabindexes in focus-trap.

Also updates the docs/typings to make them more consistent and fix
some broken links.
  • Loading branch information
stefcameron authored Jun 26, 2023
1 parent 66b753b commit 18a093f
Show file tree
Hide file tree
Showing 8 changed files with 175 additions and 45 deletions.
5 changes: 5 additions & 0 deletions .changeset/lovely-seals-burn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'tabbable': minor
---

Add new `getTabIndex()` API which enables Focus-trap to determine tab indexes in the same way as Tabbable when necessary (see [focus-trap#974](https://github.com/focus-trap/focus-trap/pull/974)).
44 changes: 29 additions & 15 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ Any of the above will _not_ be considered tabbable, though, if any of the follow

- has a negative `tabindex` attribute
- has a `disabled` attribute
- either the node itself _or an ancestor of it_ is hidden via `display: none` (*see ["Display check"](#display-check) below to modify this behavior)
- either the node itself _or an ancestor of it_ is hidden via `display: none` (*see ["Display check"](#displaycheck-option) below to modify this behavior)
- has `visibility: hidden` style
- is nested under a closed `<details>` element (with the exception of the first `<summary>` element)
- is an `<input type="radio">` element and a different radio in its group is `checked`
Expand Down Expand Up @@ -70,17 +70,17 @@ npm install tabbable
```js
import { tabbable } from 'tabbable';

tabbable(rootNode, [options]);
tabbable(container, [options]);
```

- `rootNode: Node` (**Required**)
- `container: Node` (**Required**)
- `options`:
- All the [common options](#common-options).
- `includeContainer: boolean` (default: false)
- If set to `true`, `rootNode` will be included in the returned tabbable node array, if `rootNode` is tabbable.
- Note that whether this option is true or false, if the `rootNode` is [inert](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert), none of its children (deep) will be considered tabbable.
- If set to `true`, `container` will be included in the returned tabbable node array, if `container` is tabbable.
- Note that whether this option is true or false, if the `container` is [inert](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert), none of its children (deep) will be considered tabbable.

Returns an array of ordered tabbable nodes (i.e. in tab order) within the `rootNode`.
Returns an array of ordered tabbable nodes (i.e. in tab order) within the `container`.

Summary of ordering principles:

Expand Down Expand Up @@ -108,17 +108,17 @@ Returns a boolean indicating whether the provided node is considered tabbable.
```js
import { focusable } from 'tabbable';

focusable(rootNode, [options]);
focusable(container, [options]);
```

- `rootNode: Node`: **Required**
- `container: Node`: **Required**
- `options`:
- All the [common options](#common-options).
- `includeContainer: boolean` (default: false)
- If set to `true`, `rootNode` will be included in the returned focusable node array, if `rootNode` is focusable.
- Note that whether this option is true or false, if the `rootNode` is [inert](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert), none of its children (deep) will be considered focusable.
- If set to `true`, `container` will be included in the returned focusable node array, if `container` is focusable.
- Note that whether this option is true or false, if the `container` is [inert](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert), none of its children (deep) will be considered focusable.

Returns an array of focusable nodes within the `rootNode`, in DOM order. This will not match the order in which `tabbable()` returns nodes.
Returns an array of focusable nodes within the `container`, in DOM order. This will not match the order in which `tabbable()` returns nodes.

### isFocusable

Expand All @@ -136,6 +136,20 @@ Returns a boolean indicating whether the provided node is considered _focusable_

> 💬 All tabbable elements are focusable, but not all focusable elements are tabbable. For example, elements with `tabindex="-1"` are focusable but not tabbable. Also note that if the node has an[inert](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/inert) ancestor, it will not be focusable.
### getTabIndex

```js
import { getTabIndex } from 'tabbable';

getTabIndex(node);
```

- `node: Element` (**Required**)

Returns a negative, 0, or positive number that expresses the node's tab index in the DOM, with exceptions made where there are browser inconsistencies related to `<audio>`, `<video>`, `<details>`, and elements with the `contenteditable="true"` attribute.

The specific exceptions may change over time. See the implementation for specific behavior.

## Common Options

These options apply to all APIs.
Expand Down Expand Up @@ -179,20 +193,20 @@ Type: `boolean | (node: FocusableElement) => ShadowRoot | boolean | undefined`
- `true` simply enables shadow DOM support for any __open__ shadow roots, but never presumes there is an undisclosed shadow. This is the equivalent of setting `getShadowRoot: () => false`
- `false` (default) disables shadow DOM support in so far as calculated tab order and closed shadow roots are concerned. If a child of a shadow (open or closed) is given to `isTabbable()` or `isFocusable()`, the shadow DOM is still considered for visibility and display checks.
- `function`:
- `node` will be a descendent of the `rootNode` given to `tabbable()`, `isTabbable()`, `focusable()`, or `isFocusable()`.
- `node` will be a descendent of the `container` given to `tabbable()`, `isTabbable()`, `focusable()`, or `isFocusable()`.
- Returns: The node's `ShadowRoot` if available, `true` indicating a `ShadowRoot` is attached but not available (i.e. "undisclosed"), or a _falsy_ value indicating there is no shadow attached to the node.

> If set to a function, and if it returns `true`, Tabbable assumes a closed `ShadowRoot` is attached and will treat the node as a scope, iterating its children for additional tabbable/focusable candidates as though it was looking inside the shadow, but not. This will get tabbing order _closer_ to -- but not necessarily the same as -- browser order.
>
> Returning `true` from a function will also inform how the node's visibility check is done, causing tabbable to use the __non-zero-area__ [Display Check](#display-check) when determining if it's visible, and so tabbable/focusable.
> Returning `true` from a function will also inform how the node's visibility check is done, causing tabbable to use the __non-zero-area__ [Display Check](#displaycheck-option) when determining if it's visible, and so tabbable/focusable.
## More details

- **Tabbable tries to identify elements that are reliably tabbable across (not dead) browsers.** Browsers are inconsistent in their behavior, though — especially for edge-case elements like `<object>` and `<iframe>` — so this means _some_ elements that you _can_ tab to in _some_ browsers will be left out of the results. (To learn more about this inconsistency, see this [amazing table](https://allyjs.io/data-tables/focusable.html)). To provide better consistency across browsers and ensure the elements you _want_ in your tabbables list show up there, **try adding `tabindex="0"` to edge-case elements that Tabbable ignores**.
- (Exemplifying the above ^^:) **The tabbability of `<iframe>`, `<embed>`, `<object>`, `<summary>`, and `<svg>` nodes is [inconsistent across browsers](https://allyjs.io/data-tables/focusable.html)**, so if you need an accurate read on one of these elements you should try giving it a `tabindex`. (You'll also need to pay attention to the `focusable` attribute on SVGs in Edge.) But you also might _not_ be able to get an accurate read — so you should avoid relying on it.
- **Radio groups have some edge cases, which you can avoid by always having a `checked` one in each group** (and that is what you should usually do anyway). If there is no `checked` radio in the radio group, _all_ of the radios will be considered tabbable. (Some browsers do this, otherwise don't — there's not consistency.)
- If you're thinking, "Why not just use the right `querySelectorAll`?", you _may_ be on to something ... but, as with most "just" statements, you're probably not. For example, a simple `querySelectorAll` approach will not figure out whether an element is _hidden_, and therefore not actually tabbable. (That said, if you do think Tabbable can be simplified or otherwise improved, I'd love to hear your idea.)
- jQuery UI's `:tabbable` selector ignores elements with height and width of `0`. I'm not sure why — because I've found that I can still tab to those elements. So I kept them in. Only elements hidden with `display: none` or `visibility: hidden` are left out. See ["Display check"](#display-check) below for other options.
- jQuery UI's `:tabbable` selector ignores elements with height and width of `0`. I'm not sure why — because I've found that I can still tab to those elements. So I kept them in. Only elements hidden with `display: none` or `visibility: hidden` are left out. See ["Display check"](#displaycheck-option) below for other options.
- Although Tabbable tries to deal with positive tabindexes, **you should not use positive tabindexes**. Accessibility experts seem to be in (rare) unanimous and clear consent about this: rely on the order of elements in the document.
- Safari on Mac OS X does not Tab to `<a>` elements by default: you have to change a setting to get the standard behavior. Tabbable does not know whether you've changed that setting or not, so it will include `<a>` elements in its list.

Expand All @@ -206,7 +220,7 @@ Type: `boolean | (node: FocusableElement) => ShadowRoot | boolean | undefined`
Tabbable uses some DOM APIs such as [Element.getClientRects()](https://developer.mozilla.org/en-US/docs/Web/API/Element/getClientRects) in order to determine node visibility, which helps in deciding whether a node is tabbable, focusable, or neither.

When using test engines such as Jest that use [JSDom](https://github.com/jsdom/jsdom) under the hood in order to run tests in Node.js (as opposed to using an automated browser testing tool like Cypress, Playwright, or Nightwatch where a full DOM is available), it is __highly recommended__ (if not _essential_) to set the [displayCheck](#display-check) option to `none` when calling any of the APIs in this library that accept it.
When using test engines such as Jest that use [JSDom](https://github.com/jsdom/jsdom) under the hood in order to run tests in Node.js (as opposed to using an automated browser testing tool like Cypress, Playwright, or Nightwatch where a full DOM is available), it is __highly recommended__ (if not _essential_) to set the [displayCheck](#displaycheck-option) option to `none` when calling any of the APIs in this library that accept it.

Using any other `displayCheck` setting will likely lead to failed tests due to nodes expected to be tabbable/focusable being determined to be the opposite because JSDom doesn't fully support some of the DOM APIs being used (even old ones that have been around for a long time).

Expand Down
8 changes: 6 additions & 2 deletions index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,15 @@ export declare function focusable(
): FocusableElement[];

export declare function isTabbable(
element: Element,
node: Element,
options?: CheckOptions
): boolean;

export declare function isFocusable(
element: Element,
node: Element,
options?: CheckOptions
): boolean;

export declare function getTabIndex(
node: Element,
): number;
93 changes: 67 additions & 26 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,27 @@ const getCandidatesIteratively = function (
return candidates;
};

const getTabindex = function (node, isScope) {
/**
* @private
* Determines if the node has an explicitly specified `tabindex` attribute.
* @param {HTMLElement} node
* @returns {boolean} True if so; false if not.
*/
const hasTabIndex = function (node) {
return !isNaN(parseInt(node.getAttribute('tabindex'), 10));
};

/**
* Determine the tab index of a given node.
* @param {HTMLElement} node
* @returns {number} Tab order (negative, 0, or positive number).
* @throws {Error} If `node` is falsy.
*/
const getTabIndex = function (node) {
if (!node) {
throw new Error('No node provided');
}

if (node.tabIndex < 0) {
// in Chrome, <details/>, <audio controls/> and <video controls/> elements get a default
// `tabIndex` of -1 when the 'tabindex' attribute isn't specified in the DOM,
Expand All @@ -219,15 +239,10 @@ const getTabindex = function (node, isScope) {
// order, consider their tab index to be 0.
// Also browsers do not return `tabIndex` correctly for contentEditable nodes;
// so if they don't have a tabindex attribute specifically set, assume it's 0.
//
// isScope is positive for custom element with shadow root or slot that by default
// have tabIndex -1, but need to be sorted by document order in order for their
// content to be inserted in the correct position
if (
(isScope ||
/^(AUDIO|VIDEO|DETAILS)$/.test(node.tagName) ||
(/^(AUDIO|VIDEO|DETAILS)$/.test(node.tagName) ||
isContentEditable(node)) &&
isNaN(parseInt(node.getAttribute('tabindex'), 10))
!hasTabIndex(node)
) {
return 0;
}
Expand All @@ -236,6 +251,24 @@ const getTabindex = function (node, isScope) {
return node.tabIndex;
};

/**
* Determine the tab index of a given node __for sort order purposes__.
* @param {HTMLElement} node
* @param {boolean} [isScope] True for a custom element with shadow root or slot that, by default,
* has tabIndex -1, but needs to be sorted by document order in order for its content to be
* inserted into the correct sort position.
* @returns {number} Tab order (negative, 0, or positive number).
*/
const getSortOrderTabIndex = function (node, isScope) {
const tabIndex = getTabIndex(node);

if (tabIndex < 0 && isScope && !hasTabIndex(node)) {
return 0;
}

return tabIndex;
};

const sortOrderedTabbables = function (a, b) {
return a.tabIndex === b.tabIndex
? a.documentOrder - b.documentOrder
Expand Down Expand Up @@ -520,7 +553,7 @@ const isNodeMatchingSelectorFocusable = function (options, node) {
const isNodeMatchingSelectorTabbable = function (options, node) {
if (
isNonTabbableRadio(node) ||
getTabindex(node) < 0 ||
getTabIndex(node) < 0 ||
!isNodeMatchingSelectorFocusable(options, node)
) {
return false;
Expand Down Expand Up @@ -548,7 +581,7 @@ const sortByOrder = function (candidates) {
candidates.forEach(function (item, i) {
const isScope = !!item.scopeParent;
const element = isScope ? item.scopeParent : item;
const candidateTabindex = getTabindex(element, isScope);
const candidateTabindex = getSortOrderTabIndex(element, isScope);
const elements = isScope ? sortByOrder(item.candidates) : element;
if (candidateTabindex === 0) {
isScope
Expand Down Expand Up @@ -576,40 +609,48 @@ const sortByOrder = function (candidates) {
.concat(regularTabbables);
};

const tabbable = function (el, options) {
const tabbable = function (container, options) {
options = options || {};

let candidates;
if (options.getShadowRoot) {
candidates = getCandidatesIteratively([el], options.includeContainer, {
filter: isNodeMatchingSelectorTabbable.bind(null, options),
flatten: false,
getShadowRoot: options.getShadowRoot,
shadowRootFilter: isValidShadowRootTabbable,
});
candidates = getCandidatesIteratively(
[container],
options.includeContainer,
{
filter: isNodeMatchingSelectorTabbable.bind(null, options),
flatten: false,
getShadowRoot: options.getShadowRoot,
shadowRootFilter: isValidShadowRootTabbable,
}
);
} else {
candidates = getCandidates(
el,
container,
options.includeContainer,
isNodeMatchingSelectorTabbable.bind(null, options)
);
}
return sortByOrder(candidates);
};

const focusable = function (el, options) {
const focusable = function (container, options) {
options = options || {};

let candidates;
if (options.getShadowRoot) {
candidates = getCandidatesIteratively([el], options.includeContainer, {
filter: isNodeMatchingSelectorFocusable.bind(null, options),
flatten: true,
getShadowRoot: options.getShadowRoot,
});
candidates = getCandidatesIteratively(
[container],
options.includeContainer,
{
filter: isNodeMatchingSelectorFocusable.bind(null, options),
flatten: true,
getShadowRoot: options.getShadowRoot,
}
);
} else {
candidates = getCandidates(
el,
container,
options.includeContainer,
isNodeMatchingSelectorFocusable.bind(null, options)
);
Expand Down Expand Up @@ -644,4 +685,4 @@ const isFocusable = function (node, options) {
return isNodeMatchingSelectorFocusable(options, node);
};

export { tabbable, focusable, isTabbable, isFocusable };
export { tabbable, focusable, isTabbable, isFocusable, getTabIndex };
1 change: 1 addition & 0 deletions test/fixtures/fixtures.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,5 @@ module.exports = {
path.join(__dirname, 'shadow-dom-untabbable.html'),
'utf8'
),
tabindex: fs.readFileSync(path.join(__dirname, 'tabindex.html'), 'utf8'),
};
28 changes: 28 additions & 0 deletions test/fixtures/tabindex.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<a id="anchor-tabindex-none" href="#">no tabindex</a>
<button id="btn-tabindex-1" tabindex="1">tabindex=1</button>

<div id="contenteditable-tabindex-none" contenteditable="true" style="padding:1em">
editable text, no tabindex
</div>
<div id="contenteditable-tabindex-neg" contenteditable="true" style="padding:1em" tabindex="-1">
editable text, neg tabindex
</div>

<audio id="audio-control-tabindex-none" controls></audio>
<audio id="audio-nocontrol-tabindex-none"></audio>
<audio id="audio-control-tabindex-invalid" tabindex="foo" controls></audio>
<audio id="audio-control-tabindex-2" tabindex="2" controls></audio>

<video id="video-control-tabindex-none" controls></video>
<video id="video-nocontrol-tabindex-none"></video>
<video id="video-control-tabindex-invalid" tabindex="bar" controls></video>
<video id="video-control-tabindex-3" tabindex="3" controls></video>

<details id="details-tabindex-none">
<summary>details tabindex-none title</summary>
details tabindex-none content
</details>
<details id="details-tabindex-neg" tabindex="-1">
<summary>details tabindex-neg title</summary>
details tabindex-neg content
</details>
3 changes: 2 additions & 1 deletion test/unit/node.test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
const { isFocusable, isTabbable } = require('../../src/index.js');
const { isFocusable, isTabbable, getTabIndex } = require('../../src/index.js');

describe('tabbable unit tests', () => {
it('should throw with no input node', () => {
expect(() => isFocusable()).toThrow();
expect(() => isTabbable()).toThrow();
expect(() => getTabIndex()).toThrow();
});
});
38 changes: 37 additions & 1 deletion test/unit/unit.test.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
const fixtures = require('../fixtures/fixtures');
const { tabbable, focusable } = require('../../src/index.js');
const { tabbable, focusable, getTabIndex } = require('../../src/index.js');

const getElementIds = function (elements) {
return elements.map((el) => el.id);
Expand Down Expand Up @@ -141,4 +141,40 @@ describe('unit tests', () => {
});
});
});

describe('getTabIndex', () => {
describe('tabindex example', () => {
let container;

beforeEach(() => {
container = document.createElement('div');
container.innerHTML = fixtures.tabindex;
document.body.append(container);
});

it('correctly identifies tab index of elements', () => {
const results = Array.from(container.children).map((child) => [
child.id,
getTabIndex(child),
]);

expect(results).toEqual([
['anchor-tabindex-none', 0],
['btn-tabindex-1', 1],
['contenteditable-tabindex-none', 0],
['contenteditable-tabindex-neg', -1],
['audio-control-tabindex-none', 0],
['audio-nocontrol-tabindex-none', 0],
['audio-control-tabindex-invalid', 0],
['audio-control-tabindex-2', 2],
['video-control-tabindex-none', 0],
['video-nocontrol-tabindex-none', 0],
['video-control-tabindex-invalid', 0],
['video-control-tabindex-3', 3],
['details-tabindex-none', 0],
['details-tabindex-neg', -1],
]);
});
});
});
});

0 comments on commit 18a093f

Please sign in to comment.