export const FOCUSABLE_SELECTOR = [ "a[href]", "area[href]", "button", "input", "select", "textarea", "iframe", "object", "embed", "audio[controls]", "video[controls]", "summary", "[contenteditable]", "[tabindex]", ].join(","); export interface FocusQueryOptions { /** * Include the container itself in the returned candidates when it is focusable. * Useful for modal panels with tabIndex={-1} fallback focus. */ includeContainer?: boolean; /** * Include disabled or aria-disabled controls in the candidate list. * Defaults to false because disabled controls are not keyboard-operable. */ includeDisabled?: boolean; /** * Include elements with a negative tabindex. These are programmatically * focusable but not reachable through the browser Tab sequence. */ includeNegativeTabIndex?: boolean; } export interface FocusMoveOptions extends FocusQueryOptions { focusOptions?: FocusOptions; } type InertHTMLElement = HTMLElement & { inert?: boolean }; export function isHTMLElement(value: unknown): value is HTMLElement { if (!value || typeof value !== "object") { return false; } const ownerWindow = (value as { ownerDocument?: Document }).ownerDocument?.defaultView; const HTMLElementConstructor = ownerWindow?.HTMLElement ?? (typeof HTMLElement === "undefined" ? undefined : HTMLElement); if (!HTMLElementConstructor) { return false; } return value instanceof HTMLElementConstructor; } function getComputedStyleSafe(element: HTMLElement): CSSStyleDeclaration | null { const ownerWindow = element.ownerDocument?.defaultView ?? (typeof window === "undefined" ? undefined : window); return ownerWindow?.getComputedStyle(element) ?? null; } export function isElementHidden(element: HTMLElement): boolean { for (let current: HTMLElement | null = element; current; current = current.parentElement) { if (current.hidden) { return true; } if (current.getAttribute("aria-hidden") === "true") { return true; } if (current.hasAttribute("inert") || (current as InertHTMLElement).inert === true) { return true; } const style = getComputedStyleSafe(current); if (style?.display === "none") { return true; } if (style?.visibility === "hidden" || style?.visibility === "collapse") { return true; } } return false; } function isElementInsideFirstLegend(element: HTMLElement, fieldset: Element): boolean { const firstLegend = Array.from(fieldset.children).find( (child) => child.tagName.toLowerCase() === "legend", ); return Boolean(firstLegend?.contains(element)); } export function isElementDisabled(element: HTMLElement): boolean { if ("disabled" in element && Boolean((element as { disabled?: boolean }).disabled)) { return true; } if (element.closest('[aria-disabled="true"]')) { return true; } const disabledFieldset = element.closest("fieldset[disabled]"); if (disabledFieldset && !isElementInsideFirstLegend(element, disabledFieldset)) { return true; } return false; } function getExplicitTabIndex(element: HTMLElement): number | null { const value = element.getAttribute("tabindex"); if (value === null) { return null; } const parsedValue = Number.parseInt(value, 10); return Number.isNaN(parsedValue) ? null : parsedValue; } function isContentEditableElement(element: HTMLElement): boolean { const value = element.getAttribute("contenteditable"); if (value === null) { return false; } const normalisedValue = value.trim().toLowerCase(); return normalisedValue === "" || normalisedValue === "true" || normalisedValue === "plaintext-only"; } function isFirstSummaryForDetails(element: HTMLElement): boolean { if (element.tagName.toLowerCase() !== "summary") { return false; } const parent = element.parentElement; if (!parent || parent.tagName.toLowerCase() !== "details") { return false; } return ( Array.from(parent.children).find((child) => child.tagName.toLowerCase() === "summary") === element ); } function isIntrinsicFocusable(element: HTMLElement): boolean { const tagName = element.tagName.toLowerCase(); switch (tagName) { case "a": case "area": return element.hasAttribute("href"); case "button": case "select": case "textarea": return true; case "input": return element.getAttribute("type")?.toLowerCase() !== "hidden"; case "iframe": case "object": case "embed": return true; case "audio": case "video": return element.hasAttribute("controls"); case "summary": return isFirstSummaryForDetails(element); default: return isContentEditableElement(element); } } export function getTabIndex(element: HTMLElement): number { const explicitTabIndex = getExplicitTabIndex(element); if (explicitTabIndex !== null) { return explicitTabIndex; } return isIntrinsicFocusable(element) ? 0 : -1; } export function isFocusable( element: unknown, { includeDisabled = false, includeNegativeTabIndex = false, }: Pick = {}, ): element is HTMLElement { if (!isHTMLElement(element)) { return false; } if (isElementHidden(element)) { return false; } if (!includeDisabled && isElementDisabled(element)) { return false; } if ( element.tagName.toLowerCase() === "input" && element.getAttribute("type")?.toLowerCase() === "hidden" ) { return false; } const explicitTabIndex = getExplicitTabIndex(element); const hasFocusableSemantics = explicitTabIndex !== null || isIntrinsicFocusable(element); if (!hasFocusableSemantics) { return false; } const tabIndex = getTabIndex(element); return includeNegativeTabIndex || tabIndex >= 0; } function getTabOrderBucket(tabIndex: number): 0 | 1 | 2 { if (tabIndex > 0) { return 0; } if (tabIndex === 0) { return 1; } return 2; } function sortByTabOrder(elements: HTMLElement[]): HTMLElement[] { return elements .map((element, index) => ({ element, index, tabIndex: getTabIndex(element), })) .sort((a, b) => { const aBucket = getTabOrderBucket(a.tabIndex); const bBucket = getTabOrderBucket(b.tabIndex); if (aBucket !== bBucket) { return aBucket - bBucket; } if (aBucket === 0 && a.tabIndex !== b.tabIndex) { return a.tabIndex - b.tabIndex; } return a.index - b.index; }) .map(({ element }) => element); } export function getFocusableElements( container: ParentNode | null | undefined, options: FocusQueryOptions = {}, ): HTMLElement[] { if (!container || !("querySelectorAll" in container)) { return []; } const candidates: HTMLElement[] = []; if (options.includeContainer && isHTMLElement(container)) { candidates.push(container); } candidates.push(...Array.from(container.querySelectorAll(FOCUSABLE_SELECTOR))); const uniqueCandidates = Array.from(new Set(candidates)); return sortByTabOrder(uniqueCandidates.filter((element) => isFocusable(element, options))); } function getOwnerDocument(container: ParentNode | null | undefined): Document | undefined { if (!container) { return typeof document === "undefined" ? undefined : document; } if ((container as Document).nodeType === 9) { return container as Document; } return (container as Node).ownerDocument ?? (typeof document === "undefined" ? undefined : document); } export function getActiveHTMLElement(ownerDocument?: Document): HTMLElement | null { const activeElement = ownerDocument?.activeElement ?? (typeof document === "undefined" ? undefined : document.activeElement); return isHTMLElement(activeElement) ? activeElement : null; } export function focusElement( element: HTMLElement | null | undefined, options?: FocusOptions, ): boolean { if (!element) { return false; } try { element.focus(options); } catch { element.focus(); } return getActiveHTMLElement(element.ownerDocument) === element; } export function focusFirst( container: ParentNode | null | undefined, options: FocusMoveOptions = {}, ): boolean { const [firstElement] = getFocusableElements(container, options); return focusElement(firstElement, options.focusOptions); } export function focusLast( container: ParentNode | null | undefined, options: FocusMoveOptions = {}, ): boolean { const focusableElements = getFocusableElements(container, options); return focusElement(focusableElements[focusableElements.length - 1], options.focusOptions); } function containsNode(container: ParentNode, node: Node): boolean { const maybeNode = container as Node & { contains?: (target: Node) => boolean }; return typeof maybeNode.contains === "function" ? maybeNode.contains(node) : false; } export function ensureFocusWithin( container: ParentNode | null | undefined, options: FocusMoveOptions = {}, ): boolean { if (!container) { return false; } const activeElement = getActiveHTMLElement(getOwnerDocument(container)); if (activeElement && containsNode(container, activeElement)) { return true; } return ( focusFirst(container, options) || (options.includeContainer && isHTMLElement(container) ? focusElement(container, options.focusOptions) : false) ); } export function wrapFocus( container: ParentNode | null | undefined, event: KeyboardEvent, options: FocusMoveOptions = {}, ): boolean { if (!container || event.key !== "Tab" || event.defaultPrevented) { return false; } const focusableElements = getFocusableElements(container, options); if (focusableElements.length === 0) { event.preventDefault(); ensureFocusWithin(container, { ...options, includeContainer: true, includeNegativeTabIndex: true, }); return true; } const activeElement = getActiveHTMLElement(getOwnerDocument(container)); const activeIsInsideContainer = activeElement ? containsNode(container, activeElement) : false; const currentIndex = activeElement ? focusableElements.indexOf(activeElement) : -1; if (currentIndex === -1) { if (!activeIsInsideContainer) { event.preventDefault(); const nextElement = event.shiftKey ? focusableElements[focusableElements.length - 1] : focusableElements[0]; focusElement(nextElement, options.focusOptions); return true; } return false; } if (event.shiftKey && currentIndex === 0) { event.preventDefault(); focusElement(focusableElements[focusableElements.length - 1], options.focusOptions); return true; } if (!event.shiftKey && currentIndex === focusableElements.length - 1) { event.preventDefault(); focusElement(focusableElements[0], options.focusOptions); return true; } return false; }