All files / hooks/use-focus-trap/src utils.ts

100% Statements 32/32
100% Branches 28/28
100% Functions 4/4
100% Lines 24/24

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109                1x                                                       115x   112x   112x   110x 110x       1x     109x 109x 527x 526x 525x     107x                 119x 119x           118x 112x 107x                             45x 45x                   9x 5x 2x     3x    
import type { FocusTarget } from "./types";
 
/**
 * CSS selector matching every element type that can, in principle, receive
 * keyboard focus. Elements that are `disabled` or have `tabindex="-1"` are
 * filtered out afterwards in {@link isFocusable} (a selector alone can't express
 * "any negative tabindex" or a disabled `[contenteditable]`).
 */
export const FOCUSABLE_SELECTOR = [
  "a[href]",
  "area[href]",
  "button:not([disabled])",
  "input:not([disabled])",
  "select:not([disabled])",
  "textarea:not([disabled])",
  "iframe",
  "object",
  "embed",
  "audio[controls]",
  "video[controls]",
  '[contenteditable]:not([contenteditable="false"])',
  '[tabindex]:not([tabindex="-1"])',
].join(",");
 
/**
 * Whether an element is currently hidden from interaction — and therefore not
 * focusable. Walks the ancestor chain looking for `display: none`, the `hidden`
 * attribute, or an `inert` container, and checks the element's own
 * `visibility`.
 *
 * This deliberately avoids `offsetParent`/`getBoundingClientRect` layout checks:
 * they're unreliable in non-layout environments (jsdom always reports
 * `offsetParent === null`) and would falsely hide everything. Computed-style
 * inspection works consistently in both real browsers and jsdom.
 */
export function isHidden(element: HTMLElement): boolean {
  if (element.hidden) return true;
 
  const view = element.ownerDocument.defaultView;
  // No window (detached document) — assume visible; other checks still apply.
  if (!view) return element.hasAttribute("inert");
 
  const selfStyle = view.getComputedStyle(element);
  if (
    selfStyle.visibility === "hidden" ||
    selfStyle.visibility === "collapse"
  ) {
    return true;
  }
 
  let node: HTMLElement | null = element;
  while (node) {
    if (node.hasAttribute("inert")) return true;
    if (view.getComputedStyle(node).display === "none") return true;
    node = node.parentElement;
  }
 
  return false;
}
 
/**
 * Whether a candidate element should participate in the focus cycle: it must
 * not have a negative `tabindex`, must not be `disabled`, and must be visible
 * (see {@link isHidden}).
 */
export function isFocusable(element: HTMLElement): boolean {
  const tabindex = element.getAttribute("tabindex");
  if (tabindex !== null && Number.parseInt(tabindex, 10) < 0) return false;
  // `:disabled` (unlike the `.disabled` IDL property) also matches controls
  // disabled *by an ancestor* `<fieldset disabled>` — those report
  // `.disabled === false` yet are genuinely non-interactive, so the IDL check
  // alone would wrongly keep them in the tab cycle. Matching the pseudo-class
  // excludes both the directly-disabled and the fieldset-disabled cases.
  if (element.matches(":disabled")) return false;
  if (isHidden(element)) return false;
  return true;
}
 
/**
 * Collect, **in DOM order**, every focusable descendant of `container`. Computed
 * live on demand (never cached), because the contents of a modal/dialog change
 * while it is open.
 *
 * @param container - The element to search within.
 * @returns Focusable descendants in **DOM order** — note this is not necessarily
 *   tab order: a positive `tabindex` (e.g. `tabindex="1"`) would move an element
 *   earlier in the real tab sequence, but that (discouraged) case is not
 *   reordered here.
 */
export function getFocusableElements(container: HTMLElement): HTMLElement[] {
  const nodes = container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
  return Array.from(nodes).filter(isFocusable);
}
 
/**
 * Resolve a {@link FocusTarget} (element, ref, or getter) to a concrete element,
 * or `null` if it currently points at nothing.
 *
 * @internal
 */
export function resolveFocusTarget(target: FocusTarget): HTMLElement | null {
  if (typeof target === "function") return target();
  if (typeof HTMLElement !== "undefined" && target instanceof HTMLElement) {
    return target;
  }
  // React RefObject
  return (target as { current: HTMLElement | null }).current ?? null;
}