tabbable
Version:
Returns an array of all tabbable DOM nodes within a containing node.
264 lines (226 loc) • 6.76 kB
JavaScript
const candidateSelectors = [
'input',
'select',
'textarea',
'a[href]',
'button',
'[tabindex]',
'audio[controls]',
'video[controls]',
'[contenteditable]:not([contenteditable="false"])',
'details>summary:first-of-type',
'details',
];
const candidateSelector = /* #__PURE__ */ candidateSelectors.join(',');
const matches =
typeof Element === 'undefined'
? function () {}
: Element.prototype.matches ||
Element.prototype.msMatchesSelector ||
Element.prototype.webkitMatchesSelector;
const getCandidates = function (el, includeContainer, filter) {
let candidates = Array.prototype.slice.apply(
el.querySelectorAll(candidateSelector)
);
if (includeContainer && matches.call(el, candidateSelector)) {
candidates.unshift(el);
}
candidates = candidates.filter(filter);
return candidates;
};
const isContentEditable = function (node) {
return node.contentEditable === 'true';
};
const getTabindex = function (node) {
const tabindexAttr = parseInt(node.getAttribute('tabindex'), 10);
if (!isNaN(tabindexAttr)) {
return tabindexAttr;
}
// Browsers do not return `tabIndex` correctly for contentEditable nodes;
// so if they don't have a tabindex attribute specifically set, assume it's 0.
if (isContentEditable(node)) {
return 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,
// yet they are still part of the regular tab order; in FF, they get a default
// `tabIndex` of 0; since Chrome still puts those elements in the regular tab
// order, consider their tab index to be 0.
if (
(node.nodeName === 'AUDIO' ||
node.nodeName === 'VIDEO' ||
node.nodeName === 'DETAILS') &&
node.getAttribute('tabindex') === null
) {
return 0;
}
return node.tabIndex;
};
const sortOrderedTabbables = function (a, b) {
return a.tabIndex === b.tabIndex
? a.documentOrder - b.documentOrder
: a.tabIndex - b.tabIndex;
};
const isInput = function (node) {
return node.tagName === 'INPUT';
};
const isHiddenInput = function (node) {
return isInput(node) && node.type === 'hidden';
};
const isDetailsWithSummary = function (node) {
const r =
node.tagName === 'DETAILS' &&
Array.prototype.slice
.apply(node.children)
.some((child) => child.tagName === 'SUMMARY');
return r;
};
const getCheckedRadio = function (nodes, form) {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i].checked && nodes[i].form === form) {
return nodes[i];
}
}
};
const isTabbableRadio = function (node) {
if (!node.name) {
return true;
}
const radioScope = node.form || node.ownerDocument;
const queryRadios = function (name) {
return radioScope.querySelectorAll(
'input[type="radio"][name="' + name + '"]'
);
};
let radioSet;
if (
typeof window !== 'undefined' &&
typeof window.CSS !== 'undefined' &&
typeof window.CSS.escape === 'function'
) {
radioSet = queryRadios(window.CSS.escape(node.name));
} else {
try {
radioSet = queryRadios(node.name);
} catch (err) {
// eslint-disable-next-line no-console
console.error(
'Looks like you have a radio button with a name attribute containing invalid CSS selector characters and need the CSS.escape polyfill: %s',
err.message
);
return false;
}
}
const checked = getCheckedRadio(radioSet, node.form);
return !checked || checked === node;
};
const isRadio = function (node) {
return isInput(node) && node.type === 'radio';
};
const isNonTabbableRadio = function (node) {
return isRadio(node) && !isTabbableRadio(node);
};
const isHidden = function (node, displayCheck) {
if (getComputedStyle(node).visibility === 'hidden') {
return true;
}
const isDirectSummary = matches.call(node, 'details>summary:first-of-type');
const nodeUnderDetails = isDirectSummary ? node.parentElement : node;
if (matches.call(nodeUnderDetails, 'details:not([open]) *')) {
return true;
}
if (!displayCheck || displayCheck === 'full') {
while (node) {
if (getComputedStyle(node).display === 'none') {
return true;
}
node = node.parentElement;
}
} else if (displayCheck === 'non-zero-area') {
const { width, height } = node.getBoundingClientRect();
return width === 0 && height === 0;
}
return false;
};
const isNodeMatchingSelectorFocusable = function (options, node) {
if (
node.disabled ||
isHiddenInput(node) ||
isHidden(node, options.displayCheck) ||
/* For a details element with a summary, the summary element gets the focused */
isDetailsWithSummary(node)
) {
return false;
}
return true;
};
const isNodeMatchingSelectorTabbable = function (options, node) {
if (
!isNodeMatchingSelectorFocusable(options, node) ||
isNonTabbableRadio(node) ||
getTabindex(node) < 0
) {
return false;
}
return true;
};
const tabbable = function (el, options) {
options = options || {};
const regularTabbables = [];
const orderedTabbables = [];
const candidates = getCandidates(
el,
options.includeContainer,
isNodeMatchingSelectorTabbable.bind(null, options)
);
candidates.forEach(function (candidate, i) {
const candidateTabindex = getTabindex(candidate);
if (candidateTabindex === 0) {
regularTabbables.push(candidate);
} else {
orderedTabbables.push({
documentOrder: i,
tabIndex: candidateTabindex,
node: candidate,
});
}
});
const tabbableNodes = orderedTabbables
.sort(sortOrderedTabbables)
.map((a) => a.node)
.concat(regularTabbables);
return tabbableNodes;
};
const focusable = function (el, options) {
options = options || {};
const candidates = getCandidates(
el,
options.includeContainer,
isNodeMatchingSelectorFocusable.bind(null, options)
);
return candidates;
};
const isTabbable = function (node, options) {
options = options || {};
if (!node) {
throw new Error('No node provided');
}
if (matches.call(node, candidateSelector) === false) {
return false;
}
return isNodeMatchingSelectorTabbable(options, node);
};
const focusableCandidateSelector = /* #__PURE__ */ candidateSelectors
.concat('iframe')
.join(',');
const isFocusable = function (node, options) {
options = options || {};
if (!node) {
throw new Error('No node provided');
}
if (matches.call(node, focusableCandidateSelector) === false) {
return false;
}
return isNodeMatchingSelectorFocusable(options, node);
};
export { tabbable, focusable, isTabbable, isFocusable };