dom.js 10.4 KB
import { DOCUMENT, WINDOW } from '../constants/env'
import { Element } from '../constants/safe-types'
import { from as arrayFrom } from './array'
import { isFunction, isNull } from './inspect'
import { toFloat } from './number'
import { toString } from './string'

// --- Constants ---

const ELEMENT_PROTO = Element.prototype

const TABABLE_SELECTOR = [
  'button',
  '[href]:not(.disabled)',
  'input',
  'select',
  'textarea',
  '[tabindex]',
  '[contenteditable]'
]
  .map(s => `${s}:not(:disabled):not([disabled])`)
  .join(', ')

// --- Normalization utils ---

// See: https://developer.mozilla.org/en-US/docs/Web/API/Element/matches#Polyfill
/* istanbul ignore next */
export const matchesEl =
  ELEMENT_PROTO.matches || ELEMENT_PROTO.msMatchesSelector || ELEMENT_PROTO.webkitMatchesSelector

// See: https://developer.mozilla.org/en-US/docs/Web/API/Element/closest
/* istanbul ignore next */
export const closestEl =
  ELEMENT_PROTO.closest ||
  function(sel) {
    let el = this
    do {
      // Use our "patched" matches function
      if (matches(el, sel)) {
        return el
      }
      el = el.parentElement || el.parentNode
    } while (!isNull(el) && el.nodeType === Node.ELEMENT_NODE)
    return null
  }

// `requestAnimationFrame()` convenience method
/* istanbul ignore next: JSDOM always returns the first option */
export const requestAF = (
  WINDOW.requestAnimationFrame ||
  WINDOW.webkitRequestAnimationFrame ||
  WINDOW.mozRequestAnimationFrame ||
  WINDOW.msRequestAnimationFrame ||
  WINDOW.oRequestAnimationFrame ||
  // Fallback, but not a true polyfill
  // Only needed for Opera Mini
  /* istanbul ignore next */
  (cb => setTimeout(cb, 16))
).bind(WINDOW)

export const MutationObs =
  WINDOW.MutationObserver || WINDOW.WebKitMutationObserver || WINDOW.MozMutationObserver || null

// --- Utils ---

// Remove a node from DOM
export const removeNode = el => el && el.parentNode && el.parentNode.removeChild(el)

// Determine if an element is an HTML element
export const isElement = el => !!(el && el.nodeType === Node.ELEMENT_NODE)

// Get the currently active HTML element
export const getActiveElement = (excludes = []) => {
  const { activeElement } = DOCUMENT
  return activeElement && !excludes.some(el => el === activeElement) ? activeElement : null
}

// Returns `true` if a tag's name equals `name`
export const isTag = (tag, name) => toString(tag).toLowerCase() === toString(name).toLowerCase()

// Determine if an HTML element is the currently active element
export const isActiveElement = el => isElement(el) && el === getActiveElement()

// Determine if an HTML element is visible - Faster than CSS check
export const isVisible = el => {
  if (!isElement(el) || !el.parentNode || !contains(DOCUMENT.body, el)) {
    // Note this can fail for shadow dom elements since they
    // are not a direct descendant of document.body
    return false
  }
  if (getStyle(el, 'display') === 'none') {
    // We do this check to help with vue-test-utils when using v-show
    /* istanbul ignore next */
    return false
  }
  // All browsers support getBoundingClientRect(), except JSDOM as it returns all 0's for values :(
  // So any tests that need isVisible will fail in JSDOM
  // Except when we override the getBCR prototype in some tests
  const bcr = getBCR(el)
  return !!(bcr && bcr.height > 0 && bcr.width > 0)
}

// Determine if an element is disabled
export const isDisabled = el =>
  !isElement(el) || el.disabled || hasAttr(el, 'disabled') || hasClass(el, 'disabled')

// Cause/wait-for an element to reflow its content (adjusting its height/width)
export const reflow = el => {
  // Requesting an elements offsetHight will trigger a reflow of the element content
  /* istanbul ignore next: reflow doesn't happen in JSDOM */
  return isElement(el) && el.offsetHeight
}

// Select all elements matching selector. Returns `[]` if none found
export const selectAll = (selector, root) =>
  arrayFrom((isElement(root) ? root : DOCUMENT).querySelectorAll(selector))

// Select a single element, returns `null` if not found
export const select = (selector, root) =>
  (isElement(root) ? root : DOCUMENT).querySelector(selector) || null

// Determine if an element matches a selector
export const matches = (el, selector) => (isElement(el) ? matchesEl.call(el, selector) : false)

// Finds closest element matching selector. Returns `null` if not found
export const closest = (selector, root, includeRoot = false) => {
  if (!isElement(root)) {
    return null
  }
  const el = closestEl.call(root, selector)

  // Native closest behaviour when `includeRoot` is truthy,
  // else emulate jQuery closest and return `null` if match is
  // the passed in root element when `includeRoot` is falsey
  return includeRoot ? el : el === root ? null : el
}

// Returns true if the parent element contains the child element
export const contains = (parent, child) =>
  parent && isFunction(parent.contains) ? parent.contains(child) : false

// Get an element given an ID
export const getById = id => DOCUMENT.getElementById(/^#/.test(id) ? id.slice(1) : id) || null

// Add a class to an element
export const addClass = (el, className) => {
  // We are checking for `el.classList` existence here since IE 11
  // returns `undefined` for some elements (e.g. SVG elements)
  // See https://github.com/bootstrap-vue/bootstrap-vue/issues/2713
  if (className && isElement(el) && el.classList) {
    el.classList.add(className)
  }
}

// Remove a class from an element
export const removeClass = (el, className) => {
  // We are checking for `el.classList` existence here since IE 11
  // returns `undefined` for some elements (e.g. SVG elements)
  // See https://github.com/bootstrap-vue/bootstrap-vue/issues/2713
  if (className && isElement(el) && el.classList) {
    el.classList.remove(className)
  }
}

// Test if an element has a class
export const hasClass = (el, className) => {
  // We are checking for `el.classList` existence here since IE 11
  // returns `undefined` for some elements (e.g. SVG elements)
  // See https://github.com/bootstrap-vue/bootstrap-vue/issues/2713
  if (className && isElement(el) && el.classList) {
    return el.classList.contains(className)
  }
  return false
}

// Set an attribute on an element
export const setAttr = (el, attr, value) => {
  if (attr && isElement(el)) {
    el.setAttribute(attr, value)
  }
}

// Remove an attribute from an element
export const removeAttr = (el, attr) => {
  if (attr && isElement(el)) {
    el.removeAttribute(attr)
  }
}

// Get an attribute value from an element
// Returns `null` if not found
export const getAttr = (el, attr) => (attr && isElement(el) ? el.getAttribute(attr) : null)

// Determine if an attribute exists on an element
// Returns `true` or `false`, or `null` if element not found
export const hasAttr = (el, attr) => (attr && isElement(el) ? el.hasAttribute(attr) : null)

// Set an style property on an element
export const setStyle = (el, prop, value) => {
  if (prop && isElement(el)) {
    el.style[prop] = value
  }
}

// Remove an style property from an element
export const removeStyle = (el, prop) => {
  if (prop && isElement(el)) {
    el.style[prop] = ''
  }
}

// Get an style property value from an element
// Returns `null` if not found
export const getStyle = (el, prop) => (prop && isElement(el) ? el.style[prop] || null : null)

// Return the Bounding Client Rect of an element
// Returns `null` if not an element
/* istanbul ignore next: getBoundingClientRect() doesn't work in JSDOM */
export const getBCR = el => (isElement(el) ? el.getBoundingClientRect() : null)

// Get computed style object for an element
/* istanbul ignore next: getComputedStyle() doesn't work in JSDOM */
export const getCS = el => {
  const { getComputedStyle } = WINDOW
  return getComputedStyle && isElement(el) ? getComputedStyle(el) : {}
}

// Returns a `Selection` object representing the range of text selected
// Returns `null` if no window support is given
/* istanbul ignore next: getSelection() doesn't work in JSDOM */
export const getSel = () => {
  const { getSelection } = WINDOW
  return getSelection ? WINDOW.getSelection() : null
}

// Return an element's offset with respect to document element
// https://j11y.io/jquery/#v=git&fn=jQuery.fn.offset
export const offset = el => /* istanbul ignore next: getBoundingClientRect(), getClientRects() doesn't work in JSDOM */ {
  const _offset = { top: 0, left: 0 }
  if (!isElement(el) || el.getClientRects().length === 0) {
    return _offset
  }
  const bcr = getBCR(el)
  if (bcr) {
    const win = el.ownerDocument.defaultView
    _offset.top = bcr.top + win.pageYOffset
    _offset.left = bcr.left + win.pageXOffset
  }
  return _offset
}

// Return an element's offset with respect to to its offsetParent
// https://j11y.io/jquery/#v=git&fn=jQuery.fn.position
export const position = el => /* istanbul ignore next: getBoundingClientRect() doesn't work in JSDOM */ {
  let _offset = { top: 0, left: 0 }
  if (!isElement(el)) {
    return _offset
  }
  let parentOffset = { top: 0, left: 0 }
  const elStyles = getCS(el)
  if (elStyles.position === 'fixed') {
    _offset = getBCR(el) || _offset
  } else {
    _offset = offset(el)
    const doc = el.ownerDocument
    let offsetParent = el.offsetParent || doc.documentElement
    while (
      offsetParent &&
      (offsetParent === doc.body || offsetParent === doc.documentElement) &&
      getCS(offsetParent).position === 'static'
    ) {
      offsetParent = offsetParent.parentNode
    }
    if (offsetParent && offsetParent !== el && offsetParent.nodeType === Node.ELEMENT_NODE) {
      parentOffset = offset(offsetParent)
      const offsetParentStyles = getCS(offsetParent)
      parentOffset.top += toFloat(offsetParentStyles.borderTopWidth, 0)
      parentOffset.left += toFloat(offsetParentStyles.borderLeftWidth, 0)
    }
  }
  return {
    top: _offset.top - parentOffset.top - toFloat(elStyles.marginTop, 0),
    left: _offset.left - parentOffset.left - toFloat(elStyles.marginLeft, 0)
  }
}

// Find all tabable elements in the given element
// Assumes users have not used `tabindex` > `0` on elements
export const getTabables = (rootEl = document) =>
  selectAll(TABABLE_SELECTOR, rootEl)
    .filter(isVisible)
    .filter(el => el.tabIndex > -1 && !el.disabled)

// Attempt to focus an element, and return `true` if successful
export const attemptFocus = (el, options = {}) => {
  try {
    el.focus(options)
  } catch {}
  return isActiveElement(el)
}

// Attempt to blur an element, and return `true` if successful
export const attemptBlur = el => {
  try {
    el.blur()
  } catch {}
  return !isActiveElement(el)
}