dollarsign.js

import { $ } from "./factory";

/**
 * The Dollarsign object represents a collection of DOM elements grouped by the given
 * `selector`. Wraps basic browser functionality into a more consistent
 * and concise API. The implementation of this class contains only the
 * bare minimum necessary to implement all of Dollarsign's
 * functionality, and the rest of it is implemented in various modules.
 *
 * @class
 */
export class Dollarsign {
  /**
   * Query the given DOM object (or scope) for elements matching selector.
   *
   * @constructor
   * @param {Element | Document} scope - DOM object to manipulate
   * @param {string} selector - Element query
   */
  constructor(scope, selector) {
    this.document = scope;
    this.selector = selector;
    this.events = {};

    for (const [name, plugin] of Object.entries($.fn)) {
      if (!plugin) throw new Error(`Couldn't load plugin "${name}"`);

      this[name] = plugin.bind(this);
    }

    return new Proxy(this, {
      get: (target, propKey, receiver) => {
        if (typeof propKey === "string" && this.#isSafeArrayIndex(propKey)) {
          return Reflect.get(this.elements, propKey);
        }
        return Reflect.get(target, propKey, receiver);
      },
      set: (target, propKey, value, receiver) => {
        if (typeof propKey === "string" && this.#isSafeArrayIndex(propKey)) {
          return Reflect.set(this.elements, propKey, value);
        }
        return Reflect.set(target, propKey, value, receiver);
      },
    });
  }

  /**
   * Test whether the given key that we are trying to resolve can be
   * used as an Array index. This is for internal use only, and allows
   * for Array-like behavior such as responding to `$('.selector')[0]`.
   *
   * @param {string | number} propKey
   * @return {boolean}
   * @private
   */
  #isSafeArrayIndex(propKey) {
    const uint = Number.parseInt(propKey, 10);
    const s = uint + "";

    return propKey === s && uint !== 0xffffffff && uint < this.length;
  }

  /**
   * All elements matching the given `selector`. This is calculated
   * using `querySelectorAll()` if a string selector is given.
   * Otherwise, we assume the selector is a wrapped element and return
   * it as a single-position Array.
   *
   * @return {Array<Element>}
   * @readonly
   */
  get elements() {
    if (typeof this.selector !== "string") return [this.selector];
    if (
      Array.isArray(this.document) ||
      NodeList.prototype.isPrototypeOf(this.document)
    ) {
      const elements = [];

      this.document.forEach((document) => {
        document.querySelectorAll(this.selector).forEach((element) => {
          if (!elements.includes(element)) {
            elements.push(element);
          }
        });
      });

      return elements;
    }

    return this.document.querySelectorAll(this.selector);
  }

  /**
   * Counts results in the query. As this depends on `this.elements`, it
   * is also read-only and is calculated based on the value of that
   * computed attribute.
   *
   * @return {number} Count of all elements matched by the selector.
   * @readonly
   */
  get length() {
    return this.elements.length;
  }

  /**
   * Allow iteration over this object using `for..of` loops. This is
   * also used to iterate over objects with `this.each()`, but with
   * that function we provide a bit more functionality. This will only
   * iterate over the elements and not change the `this` or wrap each
   * element in a Dollarsign object.
   * @name iterator
   * @function
   * @memberof Dollarsign
   * @instance
   * @generator
   */
  *[Symbol.iterator]() {
    for (const element of this.elements) {
      yield element;
    }
  }

  /**
   * This allows us to identify Dollarsign objects without needing to
   * use `instanceof`. In bundled environments, `instanceof` doesn't
   * work because the class names can change.
   *
   * @name toString
   * @function
   * @memberof Dollarsign
   * @instance
   */
  get [Symbol.toStringTag]() {
    return "Dollarsign";
  }

  /**
   * Function executed when iterating over a collection of DOM elements.
   *
   * @callback Dollarsign~elementIterator
   * @param {Dollarsign} element - Each element selected from the DOM.
   * @param {Number} index - Index number of each element in the Array.
   */

  /**
   * Iterate over every element with the given callback function.
   *
   * @param {elementIterator} callback - Function to call on each iteration.
   * @return {Dollarsign} this object
   */
  each(callback) {
    for (const element of this) {
      const item = new Dollarsign(this.scope, element);
      const iterate = callback.bind(item);

      iterate(element);
    }

    return this;
  }
}