Focus Utilities


(Last modified: )
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
    const focusableSelectors = [
        'a[href]',
        'area[href]',
        'input:not([disabled])',
        'select:not([disabled])',
        'textarea:not([disabled])',
        'button:not([disabled])',
        'iframe',
        'object',
        'embed',
        '[tabindex]:not([tabindex^="-"])',
        '[contenteditable]'
    ];

    const nodeList = container.querySelectorAll<HTMLElement>(focusableSelectors.join(','));
    const elements = Array.from(nodeList);

    return elements.filter((el): boolean => {
        // Explicitly check if the element is hidden
        if (el.hasAttribute('hidden')) return false;
        if (!isVisible(el)) return false;

        const style = window.getComputedStyle(el);
        if (style.visibility === 'hidden' || style.display === 'none') return false;

        if (hasHiddenParent(el, container)) return false;
        if (isInert(el, container)) return false;

        return true;
    });
}

export function getTabbableElements(container: HTMLElement): HTMLElement[] {
    return getFocusableElements(container).filter(el => {
        const tabindex = el.getAttribute('tabindex');
        return tabindex === null || parseInt(tabindex, 10) >= 0;
    });
}

// Element is visible if it has layout boxes and is not visually hidden
function isVisible(el: HTMLElement): boolean {
    return !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
}

// Returns true if any ancestor up to stopAt is hidden (via style or attribute)
function hasHiddenParent(el: HTMLElement, stopAt: HTMLElement): boolean {
    let current: HTMLElement | null = el.parentElement;

    while (current && current !== stopAt) {

        const style = window.getComputedStyle(current);

        if (
            style.display === 'none' ||
            style.visibility === 'hidden' ||
            current.hasAttribute('hidden')
        ) {
            return true;
        }

        current = current.parentElement;
    }
    return false;
}

// Returns true if any ancestor has [inert]
function isInert(el: HTMLElement, stopAt: HTMLElement): boolean {
    let current: HTMLElement | null = el;

    while (current && current !== stopAt) {

        if (current.hasAttribute('inert')) {
            return true;
        }

        current = current.parentElement;
    }

    return false;
}