All files / utils dom-search.ts

98.66% Statements 74/75
95.34% Branches 41/43
100% Functions 11/11
98.59% Lines 70/71

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                94x   94x 94x 94x                                                                                                   94x       287x 1x     286x   34x 34x 1x   33x     252x 760x     252x 2x     250x 250x 250x     250x 5x   245x 85x     245x 30x     215x       220x 43x 177x 43x   134x 48x 86x 43x 43x 43x                 220x 51x     169x                 94x       17x       94x 94x   94x   113x   94x 43x   94x 43x 43x     94x     134x   94x 43x   94x 43x   94x         86x   86x 16x     70x 70x       70x 70x 70x 170x                     170x 64x 64x       70x    
/**
 * @module Utils
 *
 * @categoryDescription DOM: Searching for reference elements
 * The functions allow you to find elements that match a given string
 * specification.
 */
 
import * as MH from "@lisn/globals/minification-helpers";
 
import { getData } from "@lisn/utils/css-alter";
import { waitForElement } from "@lisn/utils/dom-events";
import { logError } from "@lisn/utils/log";
 
/**
 * Get the element that matches the given reference specification.
 *
 * The specification is of the form:
 *
 * ```
 * <FullSpec> ::=
 *     <Relation> "." <ClassName>  |
 *     <Relation> ["-" <ReferenceName>] |
 *     #<DOM_ID>
 *
 * <Relation> :==
 *     "next"  |
 *     "prev"  |
 *     "this"  |
 *     "first" |
 *     "last"
 * ```
 *
 * - `<DOM_ID>` is the unique ID of the element
 * - `<ClassName>` is a CSS class on the element
 * - `<ReferenceName>` is the value of the `data-lisn-ref` attribute on the
 *   element you are targeting. If not given, defaults to the value of the
 *   `data-lisn-ref` attribute on `thisElement`.
 *
 * There now follows an explanation of how "next", "prev", "this", "first" and
 * "last" search the DOM:
 * - "next": the tree is search in document order (depth first, then breadth),
 *   so the returned element could be a descendant of `thisElement`
 * - "prev": the tree is search in document order (depth first, then breadth),
 *   but excluding ancestors of `thisElement`, so the returned element is
 *   guaranteed to _not_ be an ancestor or descendant of `thisElement`.
 * - "this": if the given element itself matches the specification, it is
 *   returned, otherwise the closest ancestor of the given element that matches
 *   the specification
 * - "first": the first element matching; the tree is search in document order
 *   (depth first, then breadth).
 * - "last": the last element matching; the tree is search in document order
 *   (depth first, then breadth).
 *
 * @category DOM: Searching for reference elements
 *
 * @param thisElement The element to search relative to
 *
 * @throws {@link Errors.LisnUsageError | LisnUsageError}
 *                        If the specification is invalid or if thisElement is
 *                        not given for a specification of "next", "prev" or "this"
 */
export const getReferenceElement = (
  spec: string,
  thisElement: Element,
): Element | null => {
  if (!spec) {
    return thisElement;
  }
 
  if (spec[0] === "#") {
    // element ID
    const referenceElement = MH.getElementById(spec.slice(1));
    if (!referenceElement) {
      return null;
    }
    return referenceElement;
  }
 
  const relation = ["next", "prev", "this", "first", "last"].find(
    (p) => spec.startsWith(`${p}.`) || spec.startsWith(`${p}-`) || spec === p,
  );
 
  if (!relation) {
    throw MH.usageError(`Invalid search specification '${spec}'`);
  }
 
  const rest = spec.slice(MH.lengthOf(relation));
  const matchOp = rest.slice(0, 1);
  let refOrCls = rest.slice(1);
 
  let selector: string;
  if (matchOp === ".") {
    selector = matchOp + refOrCls;
  } else {
    if (!refOrCls) {
      refOrCls = getData(thisElement, PREFIX_REF) ?? "";
    }
 
    if (!refOrCls) {
      throw MH.usageError(`No reference name in '${spec}'`);
    }
 
    selector = `[${DATA_REF}="${refOrCls}"]`;
  }
 
  let referenceElement;
  if (relation === "first") {
    referenceElement = getFirstReferenceElement(selector);
  } else if (relation === "last") {
    referenceElement = getLastReferenceElement(selector);
  } else {
    if (relation === "this") {
      referenceElement = getThisReferenceElement(selector, thisElement);
    } else if (relation === "next") {
      referenceElement = getNextReferenceElement(selector, thisElement);
    } else if (relation === "prev") {
      referenceElement = getPrevReferenceElement(selector, thisElement);
    } else E{
      /* istanbul ignore next */ {
        logError(MH.bugError(`Unhandled relation case ${relation}`));
        return null;
      }
    }
  }
 
  if (!referenceElement) {
    return null;
  }
 
  return referenceElement;
};
 
/**
 * Like {@link getReferenceElement} excepts if no element matches the
 * specification if will wait for at most the given time for such an element.
 *
 * @category DOM: Searching for reference elements
 */
export const waitForReferenceElement = (
  spec: string,
  thisElement: Element,
  timeout = 200,
) => waitForElement(() => getReferenceElement(spec, thisElement), timeout);
 
// ----------------------------------------
 
const PREFIX_REF = MH.prefixName("ref");
const DATA_REF = MH.prefixData(PREFIX_REF);
 
const getAllReferenceElements = (
  selector: string,
): NodeListOf<Element> | null => MH.docQuerySelectorAll(selector);
 
const getFirstReferenceElement = (selector: string): Element | null =>
  MH.docQuerySelector(selector);
 
const getLastReferenceElement = (selector: string): Element | null => {
  const allRefs = getAllReferenceElements(selector);
  return (allRefs && allRefs[MH.lengthOf(allRefs) - 1]) || null;
};
 
const getThisReferenceElement = (
  selector: string,
  thisElement: Element,
): Element | null => MH.closestParent(thisElement, selector);
 
const getNextReferenceElement = (selector: string, thisElement: Element) =>
  getNextOrPrevReferenceElement(selector, thisElement, false);
 
const getPrevReferenceElement = (selector: string, thisElement: Element) =>
  getNextOrPrevReferenceElement(selector, thisElement, true);
 
const getNextOrPrevReferenceElement = (
  selector: string,
  thisElement: Element,
  goBackward: boolean,
): Element | null => {
  thisElement = getThisReferenceElement(selector, thisElement) ?? thisElement;
 
  if (!MH.getDoc().contains(thisElement)) {
    return null;
  }
 
  const allRefs = getAllReferenceElements(selector);
  Iif (!allRefs) {
    return null;
  }
 
  const numRefs = MH.lengthOf(allRefs);
  let refIndex = goBackward ? numRefs - 1 : -1;
  for (let i = 0; i < numRefs; i++) {
    const currentIsAfter = MH.isNodeBAfterA(thisElement, allRefs[i]);
 
    // As soon as we find either the starting element or the first element
    // that follows it, stop iteration.
    // - If we're looking for the previous reference, then take the previous
    //   element in the iteration.
    // - Otherwise, if the current element in the iteration is the same as the
    //   starting one, then take either the next element in the iteration.
    //   - Otherwise, (if the current element follows the starting one, as
    //     would happen if the starting element was not in the list of matched
    //     elements, take the current element in the iteration.
    if (allRefs[i] === thisElement || currentIsAfter) {
      refIndex = i + (goBackward ? -1 : currentIsAfter ? 0 : 1);
      break;
    }
  }
 
  return allRefs[refIndex] ?? null;
};