2023-07-07 15:32:23 -04:00
|
|
|
import { offsetParent } from 'composed-offset-position';
|
|
|
|
|
|
2022-01-15 21:47:14 -08:00
|
|
|
/** Determines if the specified element is tabbable using heuristics inspired by https://github.com/focus-trap/tabbable */
|
2021-04-15 13:38:53 -04:00
|
|
|
function isTabbable(el: HTMLElement) {
|
|
|
|
|
const tag = el.tagName.toLowerCase();
|
2020-10-16 17:04:35 -04:00
|
|
|
|
2021-04-15 13:38:53 -04:00
|
|
|
// Elements with a -1 tab index are not tabbable
|
|
|
|
|
if (el.getAttribute('tabindex') === '-1') {
|
|
|
|
|
return false;
|
2020-10-16 17:04:35 -04:00
|
|
|
}
|
|
|
|
|
|
2021-04-15 13:38:53 -04:00
|
|
|
// Elements with a disabled attribute are not tabbable
|
|
|
|
|
if (el.hasAttribute('disabled')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Elements with aria-disabled are not tabbable
|
|
|
|
|
if (el.hasAttribute('aria-disabled') && el.getAttribute('aria-disabled') !== 'false') {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Radios without a checked attribute are not tabbable
|
|
|
|
|
if (tag === 'input' && el.getAttribute('type') === 'radio' && !el.hasAttribute('checked')) {
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Elements that are hidden have no offsetParent and are not tabbable
|
2023-07-07 15:32:23 -04:00
|
|
|
// offsetParent() is added because otherwise it misses elements in Safari
|
|
|
|
|
if (el.offsetParent === null && offsetParent(el) === null) {
|
2021-04-15 13:38:53 -04:00
|
|
|
return false;
|
2020-10-16 17:04:35 -04:00
|
|
|
}
|
|
|
|
|
|
2021-06-29 07:08:49 -04:00
|
|
|
// Elements without visibility are not tabbable
|
2021-04-15 13:38:53 -04:00
|
|
|
if (window.getComputedStyle(el).visibility === 'hidden') {
|
|
|
|
|
return false;
|
2020-10-16 17:04:35 -04:00
|
|
|
}
|
|
|
|
|
|
2021-06-29 07:08:49 -04:00
|
|
|
// Audio and video elements with the controls attribute are tabbable
|
|
|
|
|
if ((tag === 'audio' || tag === 'video') && el.hasAttribute('controls')) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Elements with a tabindex other than -1 are tabbable
|
|
|
|
|
if (el.hasAttribute('tabindex')) {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Elements with a contenteditable attribute are tabbable
|
|
|
|
|
if (el.hasAttribute('contenteditable') && el.getAttribute('contenteditable') !== 'false') {
|
|
|
|
|
return true;
|
|
|
|
|
}
|
|
|
|
|
|
2021-04-15 13:38:53 -04:00
|
|
|
// At this point, the following elements are considered tabbable
|
|
|
|
|
return ['button', 'input', 'select', 'textarea', 'a', 'audio', 'video', 'summary'].includes(tag);
|
|
|
|
|
}
|
|
|
|
|
|
2022-01-15 21:47:14 -08:00
|
|
|
/**
|
|
|
|
|
* Returns the first and last bounding elements that are tabbable. This is more performant than checking every single
|
|
|
|
|
* element because it short-circuits after finding the first and last ones.
|
|
|
|
|
*/
|
2021-07-07 07:59:06 -04:00
|
|
|
export function getTabbableBoundary(root: HTMLElement | ShadowRoot) {
|
2023-07-07 15:32:23 -04:00
|
|
|
const tabbableElements = getTabbableElements(root);
|
|
|
|
|
|
|
|
|
|
// Find the first and last tabbable elements
|
|
|
|
|
const start = tabbableElements[0] ?? null;
|
|
|
|
|
const end = tabbableElements[tabbableElements.length - 1] ?? null;
|
|
|
|
|
|
|
|
|
|
return { start, end };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function getTabbableElements(root: HTMLElement | ShadowRoot) {
|
2021-07-07 07:59:06 -04:00
|
|
|
const allElements: HTMLElement[] = [];
|
|
|
|
|
|
|
|
|
|
function walk(el: HTMLElement | ShadowRoot) {
|
2023-07-07 15:32:23 -04:00
|
|
|
if (el instanceof Element) {
|
2021-07-07 07:59:06 -04:00
|
|
|
allElements.push(el);
|
|
|
|
|
|
2022-01-15 21:47:14 -08:00
|
|
|
if (el.shadowRoot !== null && el.shadowRoot.mode === 'open') {
|
2021-07-07 07:59:06 -04:00
|
|
|
walk(el.shadowRoot);
|
|
|
|
|
}
|
2021-04-15 13:38:53 -04:00
|
|
|
}
|
|
|
|
|
|
2022-06-24 15:37:38 +03:00
|
|
|
[...el.children].forEach((e: HTMLElement) => walk(e));
|
2021-04-15 13:38:53 -04:00
|
|
|
}
|
|
|
|
|
|
2021-07-07 07:59:06 -04:00
|
|
|
// Collect all elements including the root
|
|
|
|
|
walk(root);
|
2021-04-15 13:38:53 -04:00
|
|
|
|
2023-07-07 15:32:23 -04:00
|
|
|
return allElements.filter(isTabbable).sort((a, b) => {
|
|
|
|
|
// Make sure we sort by tabindex.
|
|
|
|
|
const aTabindex = Number(a.getAttribute('tabindex')) || 0;
|
|
|
|
|
const bTabindex = Number(b.getAttribute('tabindex')) || 0;
|
|
|
|
|
return bTabindex - aTabindex;
|
|
|
|
|
});
|
2020-10-16 17:04:35 -04:00
|
|
|
}
|