okiba-gang/okiba

View on GitHub
packages/dom/index.js

Summary

Maintainability
A
1 hr
Test Coverage
/**
 * @module  dom
 * @description Utilities to work with dom elements and selectors
 */
import {castArray} from '@okiba/arrays'
import { getMatcher, eventBuilder } from './utils'

/**
 * Selects a DOM Element with a certain id
 *
 * @example
 * import {byId} from '@okiba/dom'
 * const apple = byId('apple')
 * console.log(apple) // [div.apple]
 *
 * @param  {String}  id DOM id you are looking for
 *
 * @return {Element} A DOM Element matching `id`
 */
export function byId(id) {
  return document.getElementById(id)
}

/**
 * Selects a DOM Element, scoped to element
 *
 * @example
 * import {qs} from '@okiba/dom'
 * const pear = qs('.pear')
 * console.log(pear) // [div.pear]
 *
 * @param  {String}   selector            DOM Selector (tag, class, id, anything that can be passed to `querySelector` API)
 * @param  {Element}  [element=document]  DOM Element to scope the selection query, only childs of that element will be tageted
 *
 * @return {Element} A DOM Element matching `selector`
 */
export function qs(selector, element = document) {
  return element.querySelector(selector)
}

/**
 * Selects an array of DOM Elements, scoped to element
 *
 * @example
 * import {qsa} from '@okiba/dom'
 * const fruits = qsa('.fruit')
 * console.log(fruits) // [div.fruit, div.fruit]
 *
 * @param  {String}   selector            DOM Selector (tag, class, id, anything that can be passed to `querySelector` API)
 * @param  {Element}  [element=document]  DOM Element to scope the selection query, only childs of that element will be tageted
 *
 * @return {Element[]} An array of DOM elements matching `selector`
 */
export function qsa(selector, element = document) {
  return castArray(element.querySelectorAll(selector))
}

/**
 * Attaches an event listener to a DOM Element, or an array of.
 *
 * @example
 * import {qsa, on} from '@okiba/dom'
 * const buttons = qsa('.button')
 *
 * on(buttons, 'click', onClick)
 * on(buttons, ['mouseenter', 'mouseleve'], onMouseChange)
 *
 * // adds `onClick` to 'click' and `onMouseChange` to both 'mouseenter' and 'mouseleave'
 * on(buttons, ['click', mouseenter', 'mouseleve'], [onClick, onMouseChange])
 *
 * @param {(Element|Element[])} [window] source
 * the element which will trigger the event
 * @param {(String|String[])} type
 * the event name to bind. Or an array of
 * @param {(Function|Function[])} handler
 * the callback to be fired at the event. If an array is supplied the handlers will be bound in order,
 * if there are less handlers than event types, the last handler is bound to all remaining events.
 *
 * @return {Boolean} Success of the binding
 */
export function on(source, type, handler, options) {
  return eventBuilder(source, type, handler, 'add', options)
}

/**
 * Detached an event listener from a DOM Element, or an array of.
 *
 * @example
 * import {qs, off} from '@okiba/dom'
 * const button = qs('.button')
 *
 * button.addEventListener('click', onButtonClick)
 * // or okiba's `on` on(button, 'click')
 *
 * off(button, 'click', onButtonClick)
 *
 * // removes `onMouseChange` from both 'mouseenter' and 'mouseleave'
 * off(buttons, ['mouseenter', 'mouseleve'], onMouseChange)
 *
 * // removes `onClick` from 'click' and `onMouseChange` from both 'mouseenter' and 'mouseleave'
 * off(buttons, ['click', mouseenter', 'mouseleve'], [onClick, onMouseChange])
 *
 * @param {(Element|Element[])} [window] source
 * Element which will trigger the event
 * @param {(String|String[])} type
 * Event name to unbind. Or an array of
 * @param {(Function|Function[])} handler
 * Callback bound to the event. If an array is supplied the handlers will be unbound in order,
 * if there are less handlers than event types, the last handler is unbound from all remaining events.
 *
 * @return {Boolean} Success of the unbinding
 */
export function off(source, type, handler, options) {
  return eventBuilder(source, type, handler, 'remove', options)
}

/**
 *
 * Read mouse and touch position in the same way
 *
 * @example
 * import {eventCoords, on} from '@okiba/dom'
 * on(window, ['mousemove', 'touchmove'], onMove)
 *
 * function onMove(e){
 *  const coords = eventCoords(e)
 *  console.log(coords)
 * }
 *
 * @param {Event} DOM Event
 *
 * @return {Object} Event position coordinates (clientX and ClientY)
 */
export function eventCoords(event) {
  let coords = event
  if (event.type.indexOf('touch') === 0) {
    coords = event.touches[0] || event.changedTouches[0]
  }
  return {
    clientX: coords.clientX,
    clientY: coords.clientY,
  }
}

/**
 * Gets top and left offsets of an element
 *
 * @example
 * import {qs, offset} from '@okiba/dom'
 * const el = qs('.something')
 * const offsets = offset(el)
 * console.log(offsets) // Logs: {top: 100, left: 100}
 *
 * @param {Element} el The element you want to get offsets of
 *
 * @return {Object} Object containing `top` and `left` offsets
 */
export function offset(el) {
  let left = 0
  let top = 0

  while (el && !isNaN(el.offsetLeft) && !isNaN(el.offsetTop)) {
    left += el.offsetLeft - (el.tagName !== 'BODY' ? el.scrollLeft : 0)
    top += el.offsetTop - (el.tagName !== 'BODY' ? el.scrollTop : 0)
    el = el.offsetParent
  }

  return {
    top,
    left
  }
}


/**
 * Useful to normalize parameters accepted by modules which work with dom nodes.
 * If you need to have an array of Elements and you want to accept any of: String, String array, Element, Element array
 *
 *
 * @example
 * import {qs, getElements} from '@okiba/dom'
 * const els1 = getElements(['.some', '#thing']) // => [div.some, span#it]
 *
 * const el = qs('.element')
 * const els2 = getElements(el) // => [el]
 *
 * @param {(String|String[]|Element|Element[])} target The target you want to be sure to obtain as an array of Elements
 *
 * @return {Element[]} An array of Elements
 */
export function getElements(target) {
  let els

  if (typeof target === 'string') {
    els = qsa(target)
  }

  if (target instanceof Node) {
    els = [target]
  }

  if (target instanceof NodeList) {
    els = castArray(target)
  }

  if (target instanceof Array) {
    if (target[0] instanceof Node) {
      return target
    } else if (typeof target[0] === 'string') {
      els = target.reduce((acc, curr) => acc.concat(qsa(curr)), [])
    }
  }

  if (!els) {
    throw new Error('No target provided')
  }

  return els
}


/**
 * Checks if an element matches at least one in a list of selectors.
 *
 * @example
 * import {matches} from '@okiba/dom'
 *
 * const isInternal = !!matches(a, '.internal')
 * //...
 * const match = matches(myDiv, ['.red', '.green', '.blue])
 * myDiv.style.backgroundColor = match.replace('.', '')
 *
 * @param {Element} el Element to check
 * @param {(String|Array)} selectors Selector (ora array thereof) which the element should match
 * @param {Boolean} testAncestors If true, extends match test upward in the ancestors
 *
 * @return {String|null} First matching selector, `null` if there was no match
 */
export function matches(el, selectors = [], testAncestors) {
  const matcher = getMatcher()
  let matched = castArray(selectors).find(selector => (el[matcher] && el[matcher](selector)))

  if (!matched && testAncestors) {
    matched = castArray(selectors).find(selector => isChildOf(el, selector))
  }

  return matched
}

/**
 * Check if a given element is child of another. The target to match can be an element, selector, or array of selectors.
 *
 * @example
 * import {isChildOf} from '@okiba/dom'
 *
 * const isChildOfAnchor = isChildOf(myNode, 'a')
 * //... or
 * const isInsideButton = isChildOf(myNode, myButton)
 *
 * @param {Element} el Element to check
 * @param {(Element|String|String[])} target Selector to match or Element checked for parent relationship
 *
 * @return {Boolean} Boolean of match found
 */
export function isChildOf(el, target) {
  const isSelector = typeof target === 'string'
  let isMatching = false

  do {
    isMatching = isSelector
      ? matches(el, target)
      : el === target

    el = el.parentNode
  } while (!isMatching && el)

  return !!isMatching
}

/**
 * Delegate an event callback.
 * It will be executed only if the event target has an ancestor which matches the given target
 *
 * @example
 * import {delegate} from '@okiba/dom'
 *
 * const undelegate = delegate('a.internal-navigation', 'click', onNavigationClick, {capture: true})
 *
 * function disableNavigation() {
 *   undelegate()
 * }
 *
 * @param {(String|Element)} target Selector or Element to match
 * @param {String} event Event to bind to
 * @param {Function} callback Function to be executed at match
 * @param {(Object|Boolean)} options Options to be to `on`
 * @param {(Window|HTMLDocument|HTMLElement)} context Delegation root element
 *
 * @return {Function} Function to be called to remove the delegated callback
 */
export function delegate(target, event, callback, options, context = window) {
  function check(e) {
    if (isChildOf(e.target, target)) {
      callback(e)
    }
  }

  on(context, event, check, options)
  return function undelegate() {
    off(context, event, check)
  }
}

/**
 * Custom event factory.
 * Creates a cross-browsers compatible custom event instance
 *
 * @param {String} type The custom event type
 * @param {Object} options The custom event options
 *
 * @example
 * import {createCustomEvent} from '@okiba/dom'
 *
 * const enemy = document.getElementById('enemy')
 * const shinobiAttack = createCustomEvent('shinobi-attack', {
 *  detail: { damage: 3 }
 * })
 *
 * enemy.setAttribute('data-life-points', 100)
 *
 * enemy.addEventListener('shinobi-attack', e => {
 *  const currentLifePoints = enemy.getAttribute('data-life-points')
 *  const updatedlifePoints = Math.max(0, currentLifePoints - e.detail.damage)
 *  enemy.setAttribute('data-life-points', updatedlifePoints)
 * })
 *
 * enemy.dispatchEvent(shinobiAttack)
 *
 * console.log(enemy.getAttribute('data-life-points')) // Logs: 97
 *
 * @return {CustomEvent} The custom event instance
 */
export function createCustomEvent(type, options = {}) {
  const config = {
    bubbles: false,
    cancelable: false,
    detail: null,
    ...options
  }

  if (typeof window.CustomEvent === 'function') {
    return new window.CustomEvent(type, config)
  }

  const event = document.createEvent('CustomEvent')
  event.initCustomEvent(type, config.bubbles, config.cancelable, config.detail)

  return event
}