import { disposables } from '@/util/disposables' import { match } from '@/util/match' import { getOwnerDocument } from '@/util/owner' // 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 { /** Something went wrong while trying to focus. */ Error, /** When `Focus.WrapAround` is enabled, going from position `N` to `N+1` where `N` is the last index in the array, then we overflow. */ Overflow, /** Focus was successful. */ Success, /** When `Focus.WrapAround` is enabled, going from position `N` to `N-1` where `N` is the first index in the array, then we underflow. */ 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 enum FocusableMode { /** The element itself must be focusable. */ Strict, /** The element should be inside of a focusable element. */ Loose, } export function isFocusableElement( element: HTMLElement, mode: FocusableMode = FocusableMode.Strict ) { if (element === getOwnerDocument(element)?.body) return false return match(mode, { [FocusableMode.Strict]() { return element.matches(focusableSelector) }, [FocusableMode.Loose]() { let next: HTMLElement | null = element while (next !== null) { if (next.matches(focusableSelector)) return true next = next.parentElement } return false }, }) } export function restoreFocusIfNecessary(element: HTMLElement | null) { let ownerDocument = getOwnerDocument(element) disposables().nextFrame(() => { if ( ownerDocument && !isFocusableElement(ownerDocument.activeElement as HTMLElement, FocusableMode.Strict) ) { focusElement(element) } }) } export function focusElement(element: HTMLElement | null) { element?.focus({ preventScroll: true }) } // https://developer.mozilla.org/en-US/docs/Web/API/HTMLInputElement/select let selectableSelector = ['textarea', 'input'].join(',') function isSelectableElement( element: Element | null ): element is HTMLInputElement | HTMLTextAreaElement { return element?.matches?.(selectableSelector) ?? false } export function sortByDomNode( nodes: T[], resolveKey: (item: T) => HTMLElement | null = (i) => i as unknown as HTMLElement | null ): T[] { return nodes.slice().sort((aItem, zItem) => { let a = resolveKey(aItem) let z = resolveKey(zItem) if (a === null || z === null) return 0 let position = a.compareDocumentPosition(z) if (position & Node.DOCUMENT_POSITION_FOLLOWING) return -1 if (position & Node.DOCUMENT_POSITION_PRECEDING) return 1 return 0 }) } export function focusFrom(current: HTMLElement | null, focus: Focus) { return focusIn(getFocusableElements(), focus, true, current) } export function focusIn( container: HTMLElement | HTMLElement[], focus: Focus, sorted = true, active: HTMLElement | null = null ) { let ownerDocument = Array.isArray(container) ? container.length > 0 ? container[0].ownerDocument : document : container.ownerDocument let elements = Array.isArray(container) ? sorted ? sortByDomNode(container) : container : getFocusableElements(container) active = active ?? (ownerDocument.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 !== ownerDocument.activeElement) // By default if you to a text input or a textarea, the browser will // select all the text once the focus is inside these DOM Nodes. However, // since we are manually moving focus this behaviour is not happening. This // code will make sure that the text gets selected as-if you did it manually. // Note: We only do this when going forward / backward. Not for the // Focus.First or Focus.Last actions. This is similar to the `autoFocus` // behaviour on an input where the input will get focus but won't be // selected. if (focus & (Focus.Next | Focus.Previous) && isSelectableElement(next)) { next.select() } // This is a little weird, but let me try and explain: There are a few scenario's // in chrome for example where a focused `` tag does not get the default focus // styles and sometimes they do. This highly depends on whether you started by // clicking or by using your keyboard. When you programmatically add focus `anchor.focus()` // then the active element (document.activeElement) is this anchor, which is expected. // However in that case the default focus styles are not applied *unless* you // also add this tabindex. if (!next.hasAttribute('tabindex')) next.setAttribute('tabindex', '0') return FocusResult.Success }