hlfcoding/hlf-dom-extensions

View on GitHub
src/js/tip.js

Summary

Maintainability
A
0 mins
Test Coverage
//
// HLF Tip Extension
// =================
// [Styles](../css/tip.html) | [Tests](../../tests/js/tip.html)
//
// The `Tip` extension does several things. It does basic parsing of trigger
// element attributes for the tip content. It can anchor itself to a trigger by
// selecting the best direction. It can follow the cursor. It toggles its
// appearance by fading in and out and resizing. It can display custom tip
// content. It uses the `HLF.HoverIntent` extension to prevent over-queueing of
// appearance handlers. The `snapTo` option allows the tip to snap to the
// trigger element. And by default the tip locks into place. But turn on only
// one axis of snapping, and the tip will follow the mouse only on the other
// axis. For example, snapping to the x-axis will only allow the tip to shift
// along the y-axis. The x will remain constant.
//
(function(root, attach) {
  if (typeof define === 'function' && define.amd) {
    define(['hlf/core', 'hlf/hover-intent'], attach);
  } else if (typeof exports === 'object') {
    module.exports = attach(require('hlf/core'), require('hlf/hover-intent'));
  } else {
    attach(HLF, HLF.HoverIntent);
  }
})(this, function(HLF, HoverIntent) {
  'use strict';
  //
  // Tip
  // ---
  //
  // - __cursorHeight__ is the browser's cursor height. We need to know this to
  //   properly offset the tip to avoid cases of cursor-tip-stem overlap.
  //
  // - __defaultDirection__ is used as a tie-breaker when selecting the best
  //   direction. Note that the direction data structure must be an array of
  //   string components, and conventionally with `'top'`/`'bottom'` first.
  //
  // - __hasListeners__ can allow events `hlftipawake` and `hlftipwaking`,
  //   `hlftipasleep` and `hlftipsleeping` to be triggered from the trigger
  //   elements. This is off by default to improve performance.
  //
  // - __hasStem__ can be turned off to omit rendering the stem and accounting
  //   for it during layout.
  //
  // - __snapTo__ when set allows the tip to first snap to or along the trigger
  //   before mouse tracking. Null by default. Values can also be `'x'`,
  //   `'y'`, `'trigger'`.
  //
  // - __template__ should return interpolated HTML. Its context is the
  //   extension instance.
  //
  // - __toggleDelay__ delays the tip's waking or sleeping under normal cases.
  //   It defaults to 0.7 seconds.
  //
  // - __triggerContent__ can be the name of the trigger element's attribute
  //   or a function providing custom content when given the trigger element.
  //
  // - __viewportElement__ is the element in which the tip must fit. It is
  //   _not_ the context element, which by convention contains the triggers.
  //
  // - Note: the majority of presentation state logic is in the extension
  //   stylesheet. We update the presentation state by using __className__.
  //
  // To summarize the implementation, given existing `elements` in a
  // `contextElement`, a tip `element` is created and configured via
  // `_renderElement` and attached to `viewportElement`. The extension will
  // initially `_updateTriggerElements`, which effectively
  // `_updateTriggerAnchoring` and `_updateTriggerContent`.
  //
  // `HoverIntent` event listeners are added to `element` via
  // `_toggleElementEventListeners` with the `_onContentElementMouseEnter` and
  // `_onContentElementMouseLeave` handlers, and to `contextElement` via
  // `_toggleTriggerElementEventListeners` with the
  // `_onTriggerElementMouseEnter`, `_onTriggerElementMouseLeave`, and
  // `_onTriggerElementMouseMove` handlers. Aside from
  // `_onTriggerElementMouseMove` mostly wrapping `_updateElementPosition`,
  // the handlers mostly wrap `wake` and `sleep`, which `_toggleElement` in a
  // locking and delayed approach per `_updateState`, `_toggleCountdown`,
  // `toggleDelay` to avoid the tip having short lifespans or thrashing its
  // CSS-animated appearance.
  //
  // `_updateCurrentTriggerElement` calls are also typical during these actions
  // and involve updating tip anchoring and size (via `_getElementSize`). And
  // the `_contextObserver` is manually set up with the `_onContextMutation`
  // handler that `_updateTriggerElements` and also updates `elements` lists.
  //
  // `wake` will also `_updateElementPosition`, which holds the majority of the
  // tip positioning logic but offloads to `_getStemSize` and
  // `_getTriggerOffset` (and thereby `_withStealthRender`) as needed. The
  // current positioning implementation uses `offset(Height|Width|Left|Top)`,
  // `getBoundingClientRect`, `getComputedStyle`, etc. to simply calculate the
  // offset, factoring in `snapTo`. The offset is applied to the tip as a CSS
  // translate transform.
  //
  class Tip {
    static get defaults() {
      return {
        cursorHeight: 12,
        defaultDirection: ['bottom', 'right'],
        hasListeners: false,
        hasStem: true,
        repositionToFit: true,
        snapTo: null,
        template() {
          let stemHtml = this.hasStem ? `<div class="${this.className('stem')}"></div>` : '';
          return (
`<div class="${this.className('inner')}">
  ${stemHtml}
  <div class="${this.className('content')}"></div>
</div>`
          );
        },
        toggleDelay: 700,
        triggerContent: null,
        viewportElement: document.body,
      };
    }
    static toPrefix(context) {
      switch (context) {
        case 'event': return 'hlftip';
        case 'data': return 'hlf-tip';
        case 'class': return 'tips';
        case 'var': return 'tip';
        default: return 'hlf-tip';
      }
    }
    constructor(elements, options, contextElement) {
      this.elementHoverIntent = null;
      this.hoverIntent = null;
      this._currentTriggerElement = null;
      this._sleepingPosition = null;
      this._state = null;
      this._stemSize = null;
      this._toggleCountdown = null;
    }
    init() {
      this.element = document.createElement('div');
      this._updateState('asleep');
      this._renderElement();
      this._toggleContextMutationObserver(true);
      this._toggleElementEventListeners(true);
      this._toggleTriggerElementEventListeners(true);
      this._updateTriggerElements();
    }
    deinit() {
      this.element.parentNode.removeChild(this.element);
      this._toggleContextMutationObserver(false);
      this._toggleElementEventListeners(false);
      this._toggleTriggerElementEventListeners(false);
    }
    get isAsleep() { return this._state === 'asleep'; }
    get isSleeping() { return this._state === 'sleeping'; }
    get isAwake() { return this._state === 'awake'; }
    get isWaking() { return this._state === 'waking'; }
    get snapToTrigger() { return this.snapTo === 'trigger'; }
    get snapToXAxis() { return this.snapTo === 'x'; }
    get snapToYAxis() { return this.snapTo === 'y'; }
    sleep({ triggerElement, event }) {
      if (this.isAsleep || this.isSleeping) { return; }

      this._updateState('sleeping', { event });
      this.setTimeout('_toggleCountdown', this.toggleDelay, () => {
        this._toggleElement(false, () => {
          this._updateState('asleep', { event });
        });
      });
    }
    wake({ triggerElement, event }) {
      this._updateCurrentTriggerElement(triggerElement);
      if (this.isAwake || this.isWaking) { return; }

      let delayed = !this.isSleeping;
      if (!delayed) { this.debugLog('staying awake'); }
      this._updateState('waking', { event });
      this.setTimeout('_toggleCountdown', (!delayed ? 0 : this.toggleDelay), () => {
        this._toggleElement(true, () => {
          this._updateState('awake', { event });
        });
        if (event.target !== this._contentElement) {
          this._updateElementPosition(triggerElement, event);
        }
      });
    }
    //
    // `_getElementSize` does a stealth render via `_withStealthRender` to find
    // tip size. It returns saved data if possible before doing a measure. The
    // measures, used by `_updateTriggerAnchoring`, are stored on the trigger
    // as namespaced, `width` and `height` data-attributes. If on,
    // `contentOnly` will factor in content padding into the size value for the
    // current size.
    //
    // `_getStemSize` does a stealth render via `_withStealthRender` to find
    // stem size. The stem layout styles will add offset to the tip content
    // based on the tip direction. Knowing the size helps operations like
    // overall tip positioning.
    //
    // `_isTriggerDirection` deduces if `element` has the given
    // `directionComponent`, which is true if it has the classes or if there is
    // no `triggerElement` or saved direction value, and `directionComponent`
    // is part of `defaultDirection`.
    //
    // `_updateTriggerContent` comes with a very simple base implementation
    // that supports the common `title` and `alt` meta content for an element.
    // Support is also provided for the `triggerContent` option. We take that
    // content and store it into a namespaced `content` data-attribute on the
    // trigger.
    //
    _getElementSize(triggerElement, { contentOnly } = {}) {
      let size = {
        height: triggerElement.getAttribute(this.attrName('height')),
        width: triggerElement.getAttribute(this.attrName('width')),
      };
      if (!size.height || !size.width) {
        this._updateElementContent(triggerElement);
        this._withStealthRender(() => {
          triggerElement.setAttribute(this.attrName('height'),
            (size.height = this.element.offsetHeight));
          triggerElement.setAttribute(this.attrName('width'),
            (size.width = this.element.offsetWidth));
        });
      }
      if (contentOnly) {
        const { paddingTop, paddingLeft, paddingBottom, paddingRight } =
          getComputedStyle(this._contentElement);
        size.height -= parseFloat(paddingTop) + parseFloat(paddingBottom);
        size.width -= parseFloat(paddingLeft) + parseFloat(paddingRight);
      }
      return size;
    }
    _getStemSize() {
      let size = this._stemSize;
      if (size != null) { return size; }

      let stemElement = this.selectByClass('stem', this.element);
      if (!stemElement) {
        size = 0;
      } else {
        this._withStealthRender(() => {
          let margin = getComputedStyle(stemElement).margin.replace(/0px/g, '');
          size = Math.abs(parseInt(margin));
        });
      }
      this._stemSize = size;
      return size;
    }
    _getTriggerOffset(triggerElement) {
      const { position } = getComputedStyle(triggerElement);
      if (position === 'fixed' || position === 'absolute') {
        const triggerRect = triggerElement.getBoundingClientRect();
        const viewportRect = this.viewportElement.getBoundingClientRect();
        return {
          left: triggerRect.left - viewportRect.left,
          top: triggerRect.top - viewportRect.top,
        };
      } else {
        return {
          left: triggerElement.offsetLeft, top: triggerElement.offsetTop
        };
      }
    }
    _isTriggerDirection(directionComponent, triggerElement) {
      if (this.element.classList.contains(this.className(directionComponent))) {
        return true;
      }
      if (
        (!triggerElement || !triggerElement.hasAttribute(this.attrName('direction'))) &&
        this.defaultDirection.indexOf(directionComponent) !== -1
      ) {
        return true;
      }
      return false;
    }
    _onContextMutation(mutations) {
      let newTriggerElements = [];
      const allTriggerElements = [...this.querySelector(this.contextElement)];
      mutations.forEach((mutation) => {
        let triggerElements = [...mutation.addedNodes]
          .filter(n => n instanceof HTMLElement)
          .map((n) => {
            let result = this.querySelector(n);
            return result.length ? result[0] : n;
          })
          .filter(n => allTriggerElements.indexOf(n) !== -1);
        newTriggerElements = newTriggerElements.concat(triggerElements);
      });
      this._updateTriggerElements(newTriggerElements);
      this.elements = this.elements.concat(newTriggerElements);
      this.hoverIntent.elements = this.elements;
    }
    _onContentElementMouseEnter(event) {
      this.debugLog('enter tip');
      let triggerElement = this._currentTriggerElement;
      if (!triggerElement) { return; }
      this.wake({ triggerElement, event });
    }
    _onContentElementMouseLeave(event) {
      this.debugLog('leave tip');
      let triggerElement = this._currentTriggerElement;
      if (!triggerElement) { return; }
      this.sleep({ triggerElement, event });
    }
    _onTriggerElementMouseEnter(event) {
      this.wake({ triggerElement: event.target, event });
    }
    _onTriggerElementMouseLeave(event) {
      this.sleep({ triggerElement: event.target, event });
    }
    _onTriggerElementMouseMove(event) {
      const { target } = event;
      if (target.classList.contains(this.className('trigger'))) {
        this._updateCurrentTriggerElement(target);
      }
      if (
        this.isAsleep || !this._currentTriggerElement || (
          target !== this._currentTriggerElement &&
          target !== this._currentTriggerElement.parentElement &&
          !this._currentTriggerElement.contains(target)
        )
      ) {
        return;
      }
      this._updateElementPosition(this._currentTriggerElement, event);
    }
    _renderElement() {
      if (this.element.innerHTML.length) { return; }
      this.element.innerHTML = this.template();
      this.element.classList.add(
        this.className('tip'), this.className('follow'), this.className('hidden'),
        ...(this.defaultDirection.map(this.className))
      );

      this._contentElement = this.selectByClass('content', this.element);

      this.viewportElement.insertBefore(this.element, this.viewportElement.firstChild);

      if (this.snapTo) {
        this.element.classList.add(this.className((() => {
          if (this.snapToTrigger) { return 'snap-trigger'; }
          else if (this.snapToXAxis) { return 'snap-x-side'; }
          else if (this.snapToYAxis) { return 'snap-y-side'; }
        })()));
      }
    }
    _toggleContextMutationObserver(on) {
      if (!this.querySelector) { return; }
      if (!this._contextObserver) {
        this._contextObserver = new MutationObserver(this._onContextMutation);
      }
      if (on) {
        const options = { childList: true, subtree: true };
        this._contextObserver.observe(this.contextElement, options);
      } else {
        this._contextObserver.disconnect();
      }
    }
    _toggleElement(visible, completion) {
      if (this._toggleAnimation) { return; }
      const duration = this.cssVariableDuration('toggle-duration', this.element);
      let { classList, style } = this.element;
      classList.toggle(this.className('visible'), visible);
      if (visible) {
        classList.remove(this.className('hidden'));
      }
      this.setTimeout('_toggleAnimation', duration, () => {
        if (!visible) {
          classList.add(this.className('hidden'));
          style.transform = 'none';
        }
        completion();
      });
    }
    _toggleElementEventListeners(on) {
      if (this.elementHoverIntent || !on) {
        this.elementHoverIntent.remove();
        this.elementHoverIntent = null;
      }
      if (on) {
        this.elementHoverIntent = HoverIntent.extend(this._contentElement);
      }
      const { eventName } = HoverIntent;
      let listeners = {};
      listeners[eventName('enter')] = this._onContentElementMouseEnter;
      listeners[eventName('leave')] = this._onContentElementMouseLeave;
      this.toggleEventListeners(on, listeners, this._contentElement);
    }
    _toggleTriggerElementEventListeners(on) {
      if (this.hoverIntent || !on) {
        this.hoverIntent.remove();
        this.hoverIntent = null;
      }
      if (on) {
        const { contextElement } = this;
        this.hoverIntent = HoverIntent.extend(this.elements, { contextElement });
      }
      const { eventName } = HoverIntent;
      let listeners = {};
      listeners[eventName('enter')] = this._onTriggerElementMouseEnter;
      listeners[eventName('leave')] = this._onTriggerElementMouseLeave;
      listeners[eventName('track')] = this._onTriggerElementMouseMove;
      this.toggleEventListeners(on, listeners, this.contextElement);
    }
    _updateCurrentTriggerElement(triggerElement) {
      if (triggerElement == this._currentTriggerElement) { return; }

      this._updateElementContent(triggerElement);
      let contentSize = this._getElementSize(triggerElement, { contentOnly: true });
      this._contentElement.style.height = `${contentSize.height}px`;
      this._contentElement.style.width = `${contentSize.width + 1}px`; // Give some buffer.

      let { classList } = this.element;
      let compoundDirection = triggerElement.hasAttribute(this.attrName('direction')) ?
        triggerElement.getAttribute(this.attrName('direction')).split(' ') :
        this.defaultDirection;
      let directionClassNames = compoundDirection.map(this.className);
      if (!directionClassNames.reduce((memo, className) => {
        return memo && classList.contains(className);
      }, true)) {
        this.debugLog('update direction class', compoundDirection);
        classList.remove(...(['top', 'bottom', 'right', 'left'].map(this.className)));
        classList.add(...directionClassNames);
      }

      this._currentTriggerElement = triggerElement;
    }
    _updateElementContent(triggerElement) {
      const content = triggerElement.getAttribute(this.attrName('content'));
      if (content.indexOf('<') !== -1) {
        this._contentElement.innerHTML = content;
      } else {
        this._contentElement.textContent = content;
      }
    }
    _updateElementPosition(triggerElement, event) {
      let cursorHeight = this.snapTo ? 0 : this.cursorHeight;
      let offset = { left: event.detail.pageX, top: event.detail.pageY };

      if (this.snapTo) { // Note vertical directions already account for stem-size.
        let triggerOffset = this._getTriggerOffset(triggerElement);
        if (this.snapToXAxis || this.snapToTrigger) {
          offset.top = triggerOffset.top;
          if (this._isTriggerDirection('bottom', triggerElement)) {
            offset.top += triggerElement.offsetHeight;
          }
          if (!this.snapToTrigger) {
            offset.left -= this.element.offsetWidth / 2;
          }
        }
        if (this.snapToYAxis || this.snapToTrigger) {
          offset.left = triggerOffset.left;
          if (!this.snapToTrigger) {
            if (this._isTriggerDirection('right', triggerElement)) {
              offset.left += triggerElement.offsetWidth + this._getStemSize();
            } else if (this._isTriggerDirection('left', triggerElement)) {
              offset.left -= this._getStemSize();
            }
            offset.top -= this.element.offsetHeight / 2 + this._getStemSize();
          }
        }
      }

      if (this._isTriggerDirection('top', triggerElement)) {
        offset.top -= this.element.offsetHeight + this._getStemSize();
      } else if (this._isTriggerDirection('bottom', triggerElement)) {
        offset.top += cursorHeight * 2 + this._getStemSize();
      }
      if (this._isTriggerDirection('left', triggerElement)) {
        offset.left -= this.element.offsetWidth;
        if (this.element.offsetWidth > triggerElement.offsetWidth) {
          offset.left += triggerElement.offsetWidth;
        }
      }
      this.element.style.transform = `translate(${offset.left}px, ${offset.top}px)`;
    }
    _updateState(state, { event } = {}) {
      if (state === this._state) { return; }
      if (this._state) {
        if (state === 'asleep' && !this.isAsleep) { return; }
        if (state === 'awake' && !this.isWaking) { return; }
      }
      this._state = state;
      this.debugLog(state);

      if (this.hasListeners && this._currentTriggerElement) {
        this._currentTriggerElement.dispatchEvent(
          this.createCustomEvent(this._state)
        );
      }

      if (this.isAsleep || this.isAwake) {
        if (this._currentTriggerElement) {
          this._currentTriggerElement.setAttribute(
            this.attrName('has-tip-focus'), this.isAwake
          );
        }
        if (this.hoverIntent) {
          this.hoverIntent.configure({
            interval: this.isAwake ? 100 : 'default',
            sensitivity: this.isAwake ? 1 : 'default',
          });
        }
      } else if (this.isSleeping) {
        this._sleepingPosition = { x: event.detail.pageX, y: event.detail.pageY };
      } else if (this.isWaking) {
        this._sleepingPosition = null;
      }
    }
    _updateTriggerAnchoring(triggerElement) {
      let offset = this._getTriggerOffset(triggerElement);
      let height = triggerElement.offsetHeight;
      let width = triggerElement.offsetWidth;
      let tip = this._getElementSize(triggerElement);
      this.debugLog({ offset, height, width, tip });
      const viewportRect = this.viewportElement.getBoundingClientRect();
      let newDirection = this.defaultDirection.map((d) => {
        let edge, fits;
        if (d === 'bottom') {
          fits = (edge = offset.top + height + tip.height) && edge <= viewportRect.height;
        } else if (d === 'right') {
          fits = (edge = offset.left + tip.width) && edge <= viewportRect.width;
        } else if (d === 'top') {
          fits = (edge = offset.top - tip.height) && edge >= 0;
        } else if (d === 'left') {
          fits = (edge = offset.left - tips.width) && edge >= 0;
        } else {
          fits = true;
        }
        this.debugLog('check-direction-component', { d, edge });
        if (!fits && this.repositionToFit) {
          if (d === 'bottom') { return 'top'; }
          if (d === 'right') { return 'left'; }
          if (d === 'top') { return 'bottom'; }
          if (d === 'left') { return 'right'; }
        }
        return d;
      });
      triggerElement.setAttribute(this.attrName('direction'), newDirection.join(' '));
    }
    _updateTriggerContent(triggerElement) {
      const { triggerContent } = this;
      let content;
      if (typeof triggerContent === 'function') {
        content = triggerContent(triggerElement);
      } else {
        let contentAttribute;
        let shouldRemoveAttribute = true;
        if (triggerElement.hasAttribute(triggerContent)) {
          contentAttribute = triggerContent;
        } else if (triggerElement.hasAttribute('title')) {
          contentAttribute = 'title';
        } else if (triggerElement.hasAttribute('alt')) {
          contentAttribute = 'alt';
          shouldRemoveAttribute = false;
        } else {
          return console.error('Unsupported trigger.');
        }
        content = triggerElement.getAttribute(contentAttribute);
        if (shouldRemoveAttribute) {
          triggerElement.removeAttribute(contentAttribute);
        }
      }
      triggerElement.setAttribute(this.attrName('content'), content);
    }
    _updateTriggerElements(triggerElements) {
      if (!triggerElements) {
        triggerElements = this.elements;
      }
      triggerElements.forEach((triggerElement) => {
        triggerElement.classList.add(this.className('trigger'));
        this._updateTriggerContent(triggerElement);
        this._updateTriggerAnchoring(triggerElement);
      });
    }
    _withStealthRender(fn) {
      if (getComputedStyle(this.element).display !== 'none') {
        return fn();
      }
      this.swapClasses('hidden', 'visible', this.element);
      this.element.style.visibility = 'hidden';
      let result = fn();
      this.swapClasses('visible', 'hidden', this.element);
      this.element.style.visibility = 'visible';
      return result;
    }
  }
  Tip.debug = false;
  HLF.buildExtension(Tip, {
    autoBind: true,
    compactOptions: true,
    mixinNames: ['css', 'event', 'selection'],
  });
  Object.assign(HLF, { Tip });
  return Tip;
});