// Credit: // - https://stackoverflow.com/a/30753870 let focusableSelector = [ '[contentEditable=true]', '[tabindex]', 'a[href]', 'area[href]', 'button:not([disabled])', 'iframe', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', ] .map( process.env.NODE_ENV === 'test' ? // TODO: Remove this once JSDOM fixes the issue where an element that is // "hidden" can be the document.activeElement, because this is not possible // in real browsers. selector => `${selector}:not([tabindex='-1']):not([style*='display: none'])` : selector => `${selector}:not([tabindex='-1'])` ) .join(',') export enum Focus { /* Focus the first non-disabled element */ First = 1 << 0, /* Focus the previous non-disabled element */ Previous = 1 << 1, /* Focus the next non-disabled element */ Next = 1 << 2, /* Focus the last non-disabled element */ Last = 1 << 3, /* Wrap tab around */ WrapAround = 1 << 4, /* Prevent scrolling the focusable elements into view */ NoScroll = 1 << 5, } export enum FocusResult { Error, Overflow, Success, Underflow, } enum Direction { Previous = -1, Next = 1, } export function getFocusableElements(container: HTMLElement | null = document.body) { if (container == null) return [] return Array.from(container.querySelectorAll(focusableSelector)) } export function isFocusableElement(element: HTMLElement) { return element.matches(focusableSelector) } export function focusElement(element: HTMLElement | null) { element?.focus({ preventScroll: true }) } export function focusIn(container: HTMLElement | HTMLElement[], focus: Focus) { let elements = Array.isArray(container) ? container : getFocusableElements(container) let active = document.activeElement as HTMLElement let direction = (() => { if (focus & (Focus.First | Focus.Next)) return Direction.Next if (focus & (Focus.Previous | Focus.Last)) return Direction.Previous throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last') })() let startIndex = (() => { if (focus & Focus.First) return 0 if (focus & Focus.Previous) return Math.max(0, elements.indexOf(active)) - 1 if (focus & Focus.Next) return Math.max(0, elements.indexOf(active)) + 1 if (focus & Focus.Last) return elements.length - 1 throw new Error('Missing Focus.First, Focus.Previous, Focus.Next or Focus.Last') })() let focusOptions = focus & Focus.NoScroll ? { preventScroll: true } : {} let offset = 0 let total = elements.length let next = undefined do { // Guard against infinite loops if (offset >= total || offset + total <= 0) return FocusResult.Error let nextIdx = startIndex + offset if (focus & Focus.WrapAround) { nextIdx = (nextIdx + total) % total } else { if (nextIdx < 0) return FocusResult.Underflow if (nextIdx >= total) return FocusResult.Overflow } next = elements[nextIdx] // Try the focus the next element, might not work if it is "hidden" to the user. next?.focus(focusOptions) // Try the next one in line offset += direction } while (next !== document.activeElement) return FocusResult.Success }