src/utils/iterate-focusable-elements.ts (65 lines of code) (raw):
/**
* Options to the focusable elements iterator
*/
export interface IterateFocusableElements {
/**
* (Default: false) Iterate through focusable elements in reverse-order
*/
reverse?: boolean
/**
* (Default: false) Perform additional checks to determine tabbability
* which may adversely affect app performance.
*/
strict?: boolean
/**
* (Default: false) Only iterate tabbable elements, which is the subset
* of focusable elements that are part of the page's tab sequence.
*/
onlyTabbable?: boolean
}
/**
* Returns an iterator over all of the focusable elements within `container`.
* Note: If `container` is itself focusable it will be included in the results.
* @param container The container over which to find focusable elements.
* @param reverse If true, iterate backwards through focusable elements.
*/
export function* iterateFocusableElements(
container: HTMLElement,
options: IterateFocusableElements = {}
): Generator<HTMLElement, undefined, undefined> {
const strict = options.strict ?? false
const acceptFn = options.onlyTabbable ?? false ? isTabbable : isFocusable
const walker = document.createTreeWalker(container, NodeFilter.SHOW_ELEMENT, {
acceptNode: node =>
node instanceof HTMLElement && acceptFn(node, strict) ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP
})
let nextNode: Node | null = null
// Allow the container to participate
if (!options.reverse && acceptFn(container, strict)) {
yield container
}
// If iterating in reverse, continue traversing down into the last child until we reach
// a leaf DOM node
if (options.reverse) {
let lastChild = walker.lastChild()
while (lastChild) {
nextNode = lastChild
lastChild = walker.lastChild()
}
} else {
nextNode = walker.firstChild()
}
while (nextNode instanceof HTMLElement) {
yield nextNode
nextNode = options.reverse ? walker.previousNode() : walker.nextNode()
}
// Allow the container to participate (in reverse)
if (options.reverse && acceptFn(container, strict)) {
yield container
}
return undefined
}
/**
* Determines whether the given element is focusable. If `strict` is true, we may
* perform additional checks that require a reflow (less performant).
* @param elem
* @param strict
*/
export function isFocusable(elem: HTMLElement, strict = false): boolean {
// Certain conditions cause an element to never be focusable, even if they have tabindex="0"
const disabledAttrInert =
['BUTTON', 'INPUT', 'SELECT', 'TEXTAREA', 'OPTGROUP', 'OPTION', 'FIELDSET'].includes(elem.tagName) &&
(elem as HTMLElement & {disabled: boolean}).disabled
const hiddenInert = elem.hidden
const hiddenInputInert = elem instanceof HTMLInputElement && elem.type === 'hidden'
if (disabledAttrInert || hiddenInert || hiddenInputInert) {
return false
}
// Each of the conditions checked below require a reflow, thus are gated by the `strict`
// argument. If any are true, the element is not focusable, even if tabindex is set.
if (strict) {
const sizeInert = elem.offsetWidth === 0 || elem.offsetHeight === 0
const visibilityInert = ['hidden', 'collapse'].includes(getComputedStyle(elem).visibility)
const clientRectsInert = elem.getClientRects().length === 0
if (sizeInert || visibilityInert || clientRectsInert) {
return false
}
}
// Any element with `tabindex` explicitly set can be focusable, even if it's set to "-1"
if (elem.getAttribute('tabindex') != null) {
return true
}
// One last way `elem.tabIndex` can be wrong.
if (elem instanceof HTMLAnchorElement && elem.getAttribute('href') == null) {
return false
}
return elem.tabIndex !== -1
}
/**
* Determines whether the given element is tabbable. If `strict` is true, we may
* perform additional checks that require a reflow (less performant). This check
* ensures that the element is focusable and that its tabindex is not explicitly
* set to "-1" (which makes it focusable, but removes it from the tab order).
* @param elem
* @param strict
*/
export function isTabbable(elem: HTMLElement, strict = false): boolean {
return isFocusable(elem, strict) && elem.getAttribute('tabindex') !== '-1'
}