/** * Focus management helpers for focusable and tabbable elements. * @module Focus utilities */ import { contains, getActiveElement, isFrame, isVisible } from "./dom.ts"; const selector = "input:not([type='hidden']):not([disabled]), select:not([disabled]), " + "textarea:not([disabled]), a[href], button:not([disabled]), [tabindex], " + "summary, iframe, object, embed, area[href], audio[controls], " + "video[controls], [contenteditable]:not([contenteditable='false'])"; function hasNegativeTabIndex(element: Element) { const tabIndex = Number.parseInt(element.getAttribute("tabindex") || "0", 10); return tabIndex < 0; } /** * Checks whether `element` is focusable or not. * @example * isFocusable(document.querySelector("input")); // true * isFocusable(document.querySelector("input[tabindex='-1']")); // true * isFocusable(document.querySelector("input[hidden]")); // false * isFocusable(document.querySelector("input:disabled")); // false */ export function isFocusable(element: Element) { if (!element.matches(selector)) return false; if (!isVisible(element)) return false; if (element.closest("[inert]")) return false; return true; } /** * Checks whether `element` is tabbable or not. * @example * isTabbable(document.querySelector("input")); // true * isTabbable(document.querySelector("input[tabindex='-1']")); // false * isTabbable(document.querySelector("input[hidden]")); // false * isTabbable(document.querySelector("input:disabled")); // false */ export function isTabbable( element: Element | HTMLElement | HTMLInputElement, ): element is HTMLElement { if (!isFocusable(element)) return false; if (hasNegativeTabIndex(element)) return false; // If the element is a radio button in a form, we must take roving tabindex // into account. if (!("form" in element)) return true; if (!element.form) return true; if (element.checked) return true; if (element.type !== "radio") return true; // If the radio button is not part of a radio group, it's tabbable. const radioGroup = element.form.elements.namedItem(element.name); if (!radioGroup) return true; if (!("length" in radioGroup)) return true; // If we are in a radio group, we must check if the active element is part of // the same group, which would make all the other radio buttons in the group // non-tabbable. const activeElement = getActiveElement(element); if (!activeElement) return true; if (activeElement === element) return true; if (!("form" in activeElement)) return true; if (activeElement.form !== element.form) return true; if (!("name" in activeElement)) return true; if (activeElement.name !== element.name) return true; return false; } /** * Returns all the focusable elements in `container`. */ export function getAllFocusableIn( container: HTMLElement, includeContainer?: boolean, ) { const elements = Array.from( container.querySelectorAll(selector), ); if (includeContainer) { elements.unshift(container); } const focusableElements = elements.filter(isFocusable); focusableElements.forEach((element, i) => { if (!isFrame(element)) return; const frameBody = element.contentDocument?.body; if (!frameBody) return; focusableElements.splice(i, 1, ...getAllFocusableIn(frameBody)); }); return focusableElements; } /** * Returns all the focusable elements in the document. */ export function getAllFocusable(includeBody?: boolean) { return getAllFocusableIn(document.body, includeBody); } /** * Returns the first focusable element in `container`. */ export function getFirstFocusableIn( container: HTMLElement, includeContainer?: boolean, ) { const [first] = getAllFocusableIn(container, includeContainer); return first || null; } /** * Returns the first focusable element in the document. */ export function getFirstFocusable(includeBody?: boolean) { return getFirstFocusableIn(document.body, includeBody); } /** * Returns all the tabbable elements in `container`, including the container * itself. */ export function getAllTabbableIn( container: HTMLElement, includeContainer?: boolean, fallbackToFocusable?: boolean, ) { const elements = Array.from( container.querySelectorAll(selector), ); const tabbableElements = elements.filter(isTabbable); if (includeContainer && isTabbable(container)) { tabbableElements.unshift(container); } tabbableElements.forEach((element, i) => { if (!isFrame(element)) return; const frameBody = element.contentDocument?.body; if (!frameBody) return; const allFrameTabbable = getAllTabbableIn( frameBody, false, fallbackToFocusable, ); tabbableElements.splice(i, 1, ...allFrameTabbable); }); if (!tabbableElements.length && fallbackToFocusable) { // Filter the fallback to focusable elements (and expand iframes like the // tabbable path) so callers don't get non-focusable matches such as a // `display: none` input, which a `focus()` call silently ignores. return getAllFocusableIn(container); } return tabbableElements; } /** * Returns all the tabbable elements in the document. */ export function getAllTabbable(fallbackToFocusable?: boolean) { return getAllTabbableIn(document.body, false, fallbackToFocusable); } /** * Returns the first tabbable element in `container`, including the container * itself if it's tabbable. */ export function getFirstTabbableIn( container: HTMLElement, includeContainer?: boolean, fallbackToFocusable?: boolean, ): HTMLElement | null { // Unlike getAllTabbableIn, this returns as soon as a tabbable element is // found. Each isTabbable check queries style and layout, so collecting every // tabbable element just to take the first one is wasteful in containers with // many tabbable elements, such as dialogs with forms. if (includeContainer && isTabbable(container)) { if (!isFrame(container)) return container; const containerFrameBody = container.contentDocument?.body; if (!containerFrameBody) return container; const frameTabbable = getFirstTabbableIn( containerFrameBody, false, fallbackToFocusable, ); if (frameTabbable) return frameTabbable; // The container frame has no tabbable content. Fall through to the // container's own descendants, like getAllTabbableIn, which removed the // empty frame from the list and continued. } const elements = container.querySelectorAll(selector); for (const element of elements) { if (!isTabbable(element)) continue; if (isFrame(element)) { const frameBody = element.contentDocument?.body; if (!frameBody) return element; const frameTabbable = getFirstTabbableIn( frameBody, false, fallbackToFocusable, ); if (frameTabbable) return frameTabbable; continue; } return element; } if (fallbackToFocusable) { // Filter the fallback to focusable elements so callers don't get a // non-focusable match such as a `display: none` input, which a `focus()` // call silently ignores. return getFirstFocusableIn(container); } return null; } /** * Returns the first tabbable element in the document. */ export function getFirstTabbable(fallbackToFocusable?: boolean) { return getFirstTabbableIn(document.body, false, fallbackToFocusable); } /** * Returns the last tabbable element in `container`, including the container * itself if it's tabbable. */ export function getLastTabbableIn( container: HTMLElement, includeContainer?: boolean, fallbackToFocusable?: boolean, ) { const allTabbable = getAllTabbableIn( container, includeContainer, fallbackToFocusable, ); return allTabbable[allTabbable.length - 1] || null; } /** * Returns the last tabbable element in the document. */ export function getLastTabbable(fallbackToFocusable?: boolean) { return getLastTabbableIn(document.body, false, fallbackToFocusable); } interface GetTabbableInDirectionParams { container: HTMLElement; includeContainer?: boolean; reverse?: boolean; fallbackToEdge?: boolean; fallbackToFocusable?: boolean; } function getTabbableInDirection({ container, includeContainer, reverse, fallbackToEdge, fallbackToFocusable, }: GetTabbableInDirectionParams) { const activeElement = getActiveElement(container); const allFocusable = getAllFocusableIn(container, includeContainer); if (reverse) { allFocusable.reverse(); } const activeIndex = allFocusable.indexOf(activeElement as HTMLElement); const candidates = allFocusable.slice(activeIndex + 1); return ( candidates.find(isTabbable) || (fallbackToEdge ? allFocusable.find(isTabbable) : null) || (fallbackToFocusable ? candidates[0] : null) || null ); } /** * Returns the next tabbable element in `container`. */ export function getNextTabbableIn( container: HTMLElement, includeContainer?: boolean, fallbackToFirst?: boolean, fallbackToFocusable?: boolean, ) { return getTabbableInDirection({ container, includeContainer, fallbackToEdge: fallbackToFirst, fallbackToFocusable, }); } /** * Returns the next tabbable element in the document. */ export function getNextTabbable( fallbackToFirst?: boolean, fallbackToFocusable?: boolean, ) { return getNextTabbableIn( document.body, false, fallbackToFirst, fallbackToFocusable, ); } /** * Returns the previous tabbable element in `container`. * */ export function getPreviousTabbableIn( container: HTMLElement, includeContainer?: boolean, fallbackToLast?: boolean, fallbackToFocusable?: boolean, ) { return getTabbableInDirection({ container, includeContainer, reverse: true, fallbackToEdge: fallbackToLast, fallbackToFocusable, }); } /** * Returns the previous tabbable element in the document. */ export function getPreviousTabbable( fallbackToLast?: boolean, fallbackToFocusable?: boolean, ) { return getPreviousTabbableIn( document.body, false, fallbackToLast, fallbackToFocusable, ); } /** * Returns the closest focusable element. */ export function getClosestFocusable(element?: HTMLElement | null) { // Start each search from the parent so the loop always advances strictly // upward. The current element was already rejected by `isFocusable`, and the // self-inclusive `closest` would otherwise re-return a selector-matching but // non-focusable element (e.g. a box-less `display: contents` ancestor), // looping forever. while (element && !isFocusable(element)) { element = element.parentElement?.closest(selector) ?? null; } return element || null; } /** * Checks if `element` has focus. Elements that are referenced by * `aria-activedescendant` are also considered. * @example * hasFocus(document.getElementById("id")); */ export function hasFocus(element: Element) { const activeElement = getActiveElement(element); if (!activeElement) return false; if (activeElement === element) return true; const activeDescendant = activeElement.getAttribute("aria-activedescendant"); if (!activeDescendant) return false; return activeDescendant === element.id; } /** * Checks if `element` has focus within. Elements that are referenced by * `aria-activedescendant` are also considered. * @example * hasFocusWithin(document.getElementById("id")); */ export function hasFocusWithin(element: Node | Element) { const activeElement = getActiveElement(element); if (!activeElement) return false; if (contains(element, activeElement)) return true; const activeDescendant = activeElement.getAttribute("aria-activedescendant"); if (!activeDescendant) return false; if (!("id" in element)) return false; if (activeDescendant === element.id) return true; return !!element.querySelector(`#${CSS.escape(activeDescendant)}`); } /** * Focus on an element only if it's not already focused. */ export function focusIfNeeded(element: HTMLElement) { if (!hasFocusWithin(element) && isFocusable(element)) { element.focus(); } } /** * Disable focus on `element`. */ export function disableFocus(element: HTMLElement) { const currentTabindex = element.getAttribute("tabindex") ?? ""; element.setAttribute("data-tabindex", currentTabindex); element.setAttribute("tabindex", "-1"); } /** * Makes elements inside container not tabbable. */ export function disableFocusIn( container: HTMLElement, includeContainer?: boolean, ) { const tabbableElements = getAllTabbableIn(container, includeContainer); for (const element of tabbableElements) { disableFocus(element); } } /** * Restores tabbable elements inside container that were affected by * disableFocusIn. */ export function restoreFocusIn(container: HTMLElement) { const elements = container.querySelectorAll("[data-tabindex]"); const restoreTabIndex = (element: HTMLElement) => { const tabindex = element.getAttribute("data-tabindex"); element.removeAttribute("data-tabindex"); if (tabindex) { element.setAttribute("tabindex", tabindex); } else { element.removeAttribute("tabindex"); } }; if (container.hasAttribute("data-tabindex")) { restoreTabIndex(container); } for (const element of elements) { restoreTabIndex(element); } } /** * Focus on element and scroll into view. */ export function focusIntoView( element: HTMLElement, options?: ScrollIntoViewOptions, ) { if (!("scrollIntoView" in element)) { // @ts-expect-error element.focus(); } else { element.focus({ preventScroll: true }); element.scrollIntoView({ block: "nearest", inline: "nearest", ...options }); } }