browser/dom.js

/**
 * @module browser/dom
 */

import { jsonString as regexJsonString } from "../regex.js";
import { safeParse } from "../json.js";

/**
 * Determine if the script is executing in a browser environment
 * @returns {Boolean}
 */
export function isBrowser() {
  return typeof window !== "undefined" && typeof window.document !== "undefined";
}

/**
 * Remove HTML elements from string
 * @param {String} html Source HTML
 * @returns {String} String version
 */
export function stripHtmlTags(html) {
  const parseHTML = new DOMParser().parseFromString(html, "text/html");
  const text = parseHTML.body.textContent || "";
  // Replace inline tags
  return text.replace(/<\/?[^>]+(>|$)/gi, "");
}

/**
 *   Returns an array of direct descendants
 *   @param  {Node}   element
 *   @param  {String} selector
 *   @return {Array}
 */
export function getDirectDescandants(element, selector) {
  return [...element.children].filter(child => child.matches(selector));
}

/**
 *   Checks if element is overflown vertically
 *   @param  {Node}  element
 *   @return {Boolean}
 */
export function isOverflownY(element) {
  return element.scrollHeight > element.clientHeight;
}

/**
 *   Checks if element is overflown both vertically and horizontally
 *   @param  {Node}  element
 *   @return {Boolean}
 */
export function isOverflown(element) {
  return element.scrollHeight > element.clientHeight || element.scrollWidth > element.clientWidth;
}

/**
 * For a given element return the first parent that has scrollable overflow
 * - Helpful for debugging position sticky
 * @param {Node} node Node to start search for first scrollable parent
 * @returns {Node}
 * @example
 *   const $navcontent = document.querySelector(".nav__content");
 *   if ($navcontent) {
 *     console.log(getScrollParent($navcontent));
 *   }
*/
export function getScrollParent(node) {
  if (node == null) {
    return null;
  }
  if (node.scrollHeight > node.clientHeight) {
    return node;
  } else {
    return getScrollParent(node.parentNode);
  }
}

/**
 *   Returns reliable document height
 *   @return {number}
 */
export function documentHeight() {
  return Math.max(
    document.body.scrollHeight, document.documentElement.scrollHeight,
    document.body.offsetHeight, document.documentElement.offsetHeight,
    document.body.clientHeight, document.documentElement.clientHeight
  );
}


/**
 *   Returns reliable window height
 *   @return {number}
 */
export function windowHeight() {
  return Math.max(document.documentElement.clientHeight, window.innerHeight || 0);
}

/**
 *   Returns reliable window width
 *   @return {number}
 */
export function windowWidth() {
  return Math.max(document.documentElement.clientWidth, window.innerWidth || 0);
}

/**
 * Returns Node List from HTML markup string
 * @param {String} markup HTML markup to create into an element
 */
export function createElementFromHtml(markup) {
  const doc = new DOMParser().parseFromString(markup, "text/html");
  return doc.body.firstElementChild;
}

/**
 * Creates a new element with attributes and children
 * @param {Object} config Configuration object
 * @param {String} config.tag Node type (ie 'div')
 * @param {Object} config.attributes Attributes to add to the new element
 * @param {Array} config.children Array of children to append into the new element
 */
export function composeElement(config) { // tag, attributes = {}, children
  const { tag, attributes, children } = config;
  const element = document.createElement(tag);
  if (attributes) {
    Object.entries(attributes).forEach((a, v) => element.setAttribute(a, v));
  }
  if (children) {
    children.forEach(c => element.appendChild(c));
  }
  return element;
}

/**
 * Get an elements JSON dataset value
 * - Falls to empty object if no json passed
 * @param {Node} element 
 * @param {String} key key in dataset object for element
 * @param {*} [defaultValue={}] Value to fallback to if no JSON
 * @returns {Object} Empty object or JSON object from dataset
 */
export function getDatasetJson(element, key, defaultValue = {}) {
  const passed = element.dataset[key];
  return safeParse(passed, defaultValue, (error) => {
    console.error(`Error getting JSON from dataset (${ key }) -- "${ passed }"\n`, element, error);
  });
}

/**
 * Get an elements JSON dataset value that could potentially just be a single string
 * - If JSON it will return the object else it will return the value directly
 * @param {Node} element 
 * @param {String} key key in dataset object for element
 * @returns {Object|String} JSON object or current dataset value (string or empty string if no value)
 */
export function getDatasetOptionalJson(element, key) {
  const passed = element.dataset[key];
  if (passed && regexJsonString.test(passed.trim())) {
    return getDatasetJson(element, key);
  } else {
    return passed;
  }
}

/**
 * Check if a pointer event x/y was outside an elements bounding box
 * @param {Node} element - Element to test against
 * @param {Event} event - Event object for (pointer related events)
 */
export function wasClickOutside(element, event) {
  const rect = element.getBoundingClientRect();
  return (event.clientY < rect.top || // above
    event.clientY > rect.top + rect.height || // below
    event.clientX < rect.left || // left side
    event.clientX > rect.left + rect.width); // right side
}

/**
 * Resolve a target to Element
 * @param {String|Node} target The selector or node/element
 * @param {Object} context [document] The context to query possible selectors from
 * @return {HTMLElement} The element or null if not found
 */
export function getElement(target, context = document) {
  if (typeof target === "string") {
    return context.querySelector(target);
  } else if (target instanceof Element) {
    return target;
  } else {
    console.warn("getElement: Invalid target type (expected String/Node)", target);
    return null;
  }
} 

/**
 * Resolve a target to Elements
 * @param {String|Node} target The selector or node/element
 * @param {Object} context [document] The context to query possible selectors from
 * @return {Array} The elements or null if not found
 */
export function getElements(target, context = document) {
  if (typeof target === "string") {
    return [...context.querySelectorAll(target)];
  } else if (target instanceof Element) {
    return [target];
  } else if (Array.isArray(target) || target instanceof NodeList) {
    return [...target];
  } else {
    console.warn("getElement: Invalid target type (expected String/Node/Array/Node List)", target);
    return null;
  }
} 


/**
 * Sets a CSS custom property equal to the scrollbar width.
 * @param {object} options - Configuration options.
 * @param {HTMLElement} [options.scrollableChild=document.body] - An element that is a child of a scrollable container (used for width calculation).
 * @param {Window|HTMLElement} [options.container=window] - The container that can be scrolled (used for width calculation).
 * @param {HTMLElement} [options.propertyElement=document.documentElement] - The element to which the custom property will be added. Defaults to document.documentElement for :root access.
 * @param {string} [options.propertyName="--ulu-scrollbar-width"] - The name of the custom property to set.
 */
export function addScrollbarCustomProperty(options) {
  const defaults = {
    scrollableChild: document.body,
    container: window,
    propertyElement: document.documentElement,
    propertyName: "--ulu-scrollbar-width",
  };

  const config = { ...defaults, ...options };
  const { scrollableChild, container, propertyElement, propertyName } = config;

  const scrollbarWidth = getScrollbarWidth(scrollableChild, container);
  propertyElement.style.setProperty(propertyName, `${ scrollbarWidth }px`);
}

/**
 * Calculates the width of the scrollbar.
 *
 * @param {HTMLElement} [element=document.body] -The element that is the child of a scrollable container
 * @param {Window|HTMLElement} [container=window] - The container that can be scrolled
 * @returns {number} The width of the scrollbar in pixels.
 */
export function getScrollbarWidth(element = document.body, container = window) {
  return container.innerWidth - element.clientWidth;
}

/**
 * Prevents scrolling on the document body and optionally compensates for scrollbar shift.
 * Caches original body styles and returns a function to restore them.
 *
 * @param {Object} config Object of options/arguments
 * @param {HTMLElement} [config.container=document.body] - Container to prevent scroll on (defaults to document.body)
 * @param {Boolean} [config.preventShift=false] If true, adds padding-right to the container equal to the scrollbar width to prevent layout shift, defaults to false
 * @returns {Function} A restore/cleanup function that restores the original body styles.
 */
export function preventScroll({ preventShift = false, container = document.body }) {
  const cacheOverflow = container.style.overflow;
  const cachePaddingRight = container.style.paddingRight;

  container.style.overflow = "hidden"; // Apply no scroll

  // Compensate for scrollbar shift if enabled
  if (preventShift) {
    const scrollbarWidth = getScrollbarWidth();
    if (scrollbarWidth > 0) { // Only apply if a scrollbar is actually present
      const paddingRightValue = parseInt(cachePaddingRight || "0px", 10);
      container.style.paddingRight = `${ paddingRightValue + scrollbarWidth }px`;
    }
  }

  /**
   * Restores the body's original overflow and padding-right styles.
   * This function should be called when scrolling is no longer prevented.
   */
  return () => {
    container.style.overflow = cacheOverflow;
    container.style.paddingRight = cachePaddingRight;
  };
}