All files / dom dom.ts

97.56% Statements 120/123
90.8% Branches 79/87
100% Functions 30/30
99.02% Lines 102/103

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 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275        3x                   277x 277x 277x 277x 278x 277x 277x           3x         3x 1x         96x 95x 96x 96x         63x 62x 63x         8x         3x             86x                                                     53x         13x               40x         53x 53x     106x         1x 1x 3x   1x     2x     1x         5x       33x 33x   26x       26x   33x 33x 34x 33x 33x 33x 33x 33x 26x 26x                                       5x   5x 76x 76x 81x 79x 79x 1x     76x 45x   56x   78x     45x 45x 56x 56x 95x 95x 95x 95x 95x 39x     45x 72x 160x 77x 53x         77x 77x                                 38x 38x       38x 38x 38x   38x 38x 38x 38x 38x 38x 38x 38x 38x 38x 31x 31x             50x 50x 50x 111x 111x       2x   111x   50x    
import type { Unsubscribe } from '../core/types';
 
/** Returns whether global `window` and `document` objects are available at invocation time. @public */
export function hasDOM(): boolean {
  return typeof window !== 'undefined' && typeof document !== 'undefined';
}
 
/** Registers a DOM listener and returns idempotent cleanup. @internal */
export function listen<TEvent extends Event>(
  target: EventTarget,
  type: string,
  listener: (event: TEvent) => void,
  options?: AddEventListenerOptions | boolean,
): Unsubscribe {
  const callback: EventListener = (event) => listener(event as TEvent);
  target.addEventListener(type, callback, options);
  let active = true;
  return () => {
    if (!active) return;
    active = false;
    target.removeEventListener(type, callback, options);
  };
}
 
/** Resolves an element's document without consulting the global document first. @internal */
export function getOwnerDocument(element?: Node | null): Document | undefined {
  return element?.ownerDocument ?? (hasDOM() ? document : undefined);
}
 
/** Resolves a node's owning window without preferring the ambient global realm. @internal */
export function getOwnerWindow(element?: Node | null): Window | undefined {
  if (element?.nodeType === 9) return (element as Document).defaultView ?? undefined;
  return getOwnerDocument(element)?.defaultView ?? undefined;
}
 
/** @internal */
export function isHTMLElement(value: unknown, ownerDocument?: Document): value is HTMLElement {
  if (!value || typeof value !== 'object') return false;
  const document = ownerDocument ?? (value as Node).ownerDocument;
  const Constructor = document?.defaultView?.HTMLElement;
  return Boolean(Constructor && value instanceof Constructor);
}
 
/** @internal */
export function isHTMLInputElement(value: unknown): value is HTMLInputElement {
  if (!value || typeof value !== 'object') return false;
  const Constructor = (value as Node).ownerDocument?.defaultView?.HTMLInputElement;
  return Boolean(Constructor && value instanceof Constructor);
}
 
/** @internal */
export function isKeyboardLikeEvent(event: Event): event is KeyboardEvent {
  return 'key' in event && typeof event.key === 'string';
}
 
/** @internal */
export function isAbortError(error: unknown): boolean {
  return Boolean(
    error && typeof error === 'object' && 'name' in error && error.name === 'AbortError',
  );
}
 
/** Tests containment across shadow DOM using the event composed path when available. @internal */
export function eventTargets(event: Event, boundary: Node): boolean {
  return event.composedPath().includes(boundary) || boundary.contains(event.target as Node | null);
}
 
/** Options for document-level outside interaction observation. @internal */
export interface OutsideInteractionOptions {
  /** Element considered inside the owning overlay. */
  readonly boundary: Element;
  /** Additional descendant overlays that should be treated as inside. */
  readonly branches?: readonly Element[];
  /** Receives pointer interactions outside all boundaries. */
  readonly onPointerOutside?: (event: PointerEvent) => void;
  /** Receives focus transitions outside all boundaries. */
  readonly onFocusOutside?: (event: FocusEvent) => void;
}
 
/** Document-level pointer and focus callbacks shared by overlay dispatchers. @internal */
export interface DocumentInteractionOptions {
  /** Document whose capture phase should be observed. */
  readonly ownerDocument: Document;
  /** Receives every captured pointer-down interaction. */
  readonly onPointerDown?: (event: PointerEvent) => void;
  /** Receives every captured focus-in interaction. */
  readonly onFocusIn?: (event: FocusEvent) => void;
}
 
/** Observes document pointer and focus interactions with one idempotent cleanup. @internal */
export function observeDocumentInteraction(options: DocumentInteractionOptions): Unsubscribe {
  const cleanups = [
    listen<PointerEvent>(
      options.ownerDocument,
      'pointerdown',
      (event) => {
        options.onPointerDown?.(event);
      },
      true,
    ),
    listen<FocusEvent>(
      options.ownerDocument,
      'focusin',
      (event) => {
        options.onFocusIn?.(event);
      },
      true,
    ),
  ];
  return () =>
    cleanups
      .splice(0)
      .reverse()
      .forEach((cleanup) => cleanup());
}
 
/** Observes outside pointer and focus interactions and returns complete cleanup. @internal */
export function observeOutsideInteraction(options: OutsideInteractionOptions): Unsubscribe {
  const ownerDocument = options.boundary.ownerDocument;
  const isInside = (event: Event): boolean =>
    eventTargets(event, options.boundary) ||
    (options.branches?.some((branch) => eventTargets(event, branch)) ?? false);
  return observeDocumentInteraction({
    ownerDocument,
    onPointerDown(event) {
      if (!isInside(event)) options.onPointerOutside?.(event);
    },
    onFocusIn(event) {
      Eif (!isInside(event)) options.onFocusOutside?.(event);
    },
  });
}
 
const scrollLocks = new WeakMap<Document, { count: number; overflow: string }>();
 
/** Acquires a reference-counted document scroll lock and returns a release function. @internal */
export function lockDocumentScroll(ownerDocument: Document): Unsubscribe {
  const current = scrollLocks.get(ownerDocument);
  if (current) current.count += 1;
  else {
    scrollLocks.set(ownerDocument, {
      count: 1,
      overflow: ownerDocument.documentElement.style.overflow,
    });
    ownerDocument.documentElement.style.overflow = 'hidden';
  }
  let active = true;
  return () => {
    if (!active) return;
    active = false;
    const lock = scrollLocks.get(ownerDocument);
    Iif (!lock) return;
    lock.count -= 1;
    if (lock.count === 0) {
      ownerDocument.documentElement.style.overflow = lock.overflow;
      scrollLocks.delete(ownerDocument);
    }
  };
}
 
interface InertLayer {
  readonly element: Element;
  readonly branches: readonly Element[];
  readonly order: number;
  readonly token: symbol;
}
 
interface InertDocumentState {
  readonly layers: InertLayer[];
  readonly originals: Map<
    HTMLElement,
    { readonly inert: boolean; readonly ariaHidden: string | null }
  >;
}
 
const inertDocuments = new WeakMap<Document, InertDocumentState>();
 
const reconcileInertDocument = (ownerDocument: Document, state: InertDocumentState): void => {
  const topLayer = state.layers.at(-1);
  for (const [element, original] of state.originals) {
    if (!element.isConnected) continue;
    element.inert = original.inert;
    if (original.ariaHidden === null) element.removeAttribute('aria-hidden');
    else element.setAttribute('aria-hidden', original.ariaHidden);
  }
 
  if (!topLayer) return;
  const candidates = [...new Set([topLayer.element, ...topLayer.branches])].filter(
    (candidate) =>
      candidate.ownerDocument === ownerDocument &&
      ![topLayer.element, ...topLayer.branches].some(
        (other) => other !== candidate && other.contains(candidate),
      ),
  );
  const activeChildren = new Map<Element, Set<Element>>();
  for (const candidate of candidates) {
    let activeBranch = candidate;
    while (activeBranch.parentElement) {
      const parent = activeBranch.parentElement;
      const children = activeChildren.get(parent) ?? new Set<Element>();
      children.add(activeBranch);
      activeChildren.set(parent, children);
      if (parent === ownerDocument.body) break;
      activeBranch = parent;
    }
  }
  for (const [parent, children] of activeChildren) {
    for (const sibling of parent.children) {
      if (children.has(sibling) || !isHTMLElement(sibling, ownerDocument)) continue;
      if (!state.originals.has(sibling)) {
        state.originals.set(sibling, {
          inert: sibling.inert,
          ariaHidden: sibling.getAttribute('aria-hidden'),
        });
      }
      sibling.inert = true;
      sibling.setAttribute('aria-hidden', 'true');
    }
  }
};
 
/**
 * Makes document siblings inert while preserving and restoring their previous state.
 *
 * @remarks Nested modal layers are coordinated per document. Only the topmost modal branch remains
 * interactive, and releasing layers out of order cannot restore the background prematurely.
 * @internal
 */
export function inertSiblings(
  element: Element,
  branches: readonly Element[] = [],
  order = Number.MAX_SAFE_INTEGER,
): Unsubscribe {
  const ownerDocument = element.ownerDocument;
  const state: InertDocumentState = inertDocuments.get(ownerDocument) ?? {
    layers: [],
    originals: new Map(),
  };
  const layer = { element, branches, order, token: Symbol('inert-layer') };
  const insertionIndex = state.layers.findIndex((candidate) => candidate.order > order);
  if (insertionIndex < 0) state.layers.push(layer);
  else Estate.layers.splice(insertionIndex, 0, layer);
  inertDocuments.set(ownerDocument, state);
  reconcileInertDocument(ownerDocument, state);
  let active = true;
  return () => {
    Iif (!active) return;
    active = false;
    const index = state.layers.findIndex((candidate) => candidate.token === layer.token);
    Eif (index >= 0) state.layers.splice(index, 1);
    reconcileInertDocument(ownerDocument, state);
    if (state.layers.length === 0) {
      state.originals.clear();
      inertDocuments.delete(ownerDocument);
    }
  };
}
 
/** Returns scrollable ancestors that can affect anchored positioning. @internal */
export function getScrollableAncestors(element: Element): readonly Element[] {
  const result: Element[] = [];
  let parent = element.parentElement;
  while (parent) {
    const style = parent.ownerDocument.defaultView?.getComputedStyle(parent);
    if (
      style &&
      /(auto|scroll|overlay)/u.test(`${style.overflow}${style.overflowX}${style.overflowY}`)
    ) {
      result.push(parent);
    }
    parent = parent.parentElement;
  }
  return result;
}