Sabre Spark

Popover

Show and hide a popover. Should do some sanity checks on positioning as well.

Option name Type Description
module components/popover.js
Example
new Popover(el, {
  // Optional. Default anchoring of the content's x and y-axis relative to the button.
  defaultAnchorX: 'center', // 'left', 'center', 'right'
  defaultAnchorY: 'center' // 'left', 'center', 'right'
});

Popover

function
 Popover() 

Popover constructor.

Option name Type Description
el Element
params Object
var Popover = function(el, params) {

  if (!el) {
    return;
  }

  this._setParams(this.defaults, true);
  this._setParams(params || {});
  this._cacheElements(el);
  this._bindEventListenerCallbacks();
  this._addEventListeners();
};

Popover.prototype = {

_setParams

property
 _setParams 

Include common functionality.

_setParams: Base.setParams,
_toggleClass: Base.toggleClass,
_hasClass: Base.hasClass,
_addClass: Base.addClass,
_removeClass: Base.removeClass,
_elementHasParent: Base.elementHasParent,
_getElementMatchingParent: Base.getElementMatchingParent,
_getElementOffset: Base.getElementOffset,
_appendChildren: Base.appendChildren,
remove: Base.remove,

_whitelistedParams

property
 _whitelistedParams 

Whitelisted parameters which can be set on construction.

Option name Type Description
_whitelistedParams: ['defaultAnchorX', 'defaultAnchorY', 'direction', 'contentEl', 'onOpen', 'onClose'],

defaults

property
 defaults 

Default values for internal properties we will be setting.
These are set on each construction so we don't leak properties
into the prototype chain.

Option name Type Description
defaults: {
  el: null,
  toggleEl: null,
  contentEl: null,
  caretEl: null,
  anchorTo: null,
  isActive: false,
  isPaused: false,
  direction: 'bottom',
  defaultAnchorX: 'center',
  defaultAnchorY: 'center',
  closeTimer: null,
  onOpen: null,
  onClose: null,
  _onClickBound: null,
  _onContentClickBound: null,
  _onWindowClickBound: null,
  _onWindowResizeBound: null
},

open

method
 open() 

Open.

Option name Type Description
params Object

Optional

open: function(params) {

  params = params || {};

  // If there is a timer running for the close event, clear it so it doesn't close stuff during open.
  if (this.closeTimer) {
    clearTimeout(this.closeTimer);
    this.closeTimer = null;
  }

  this.anchorTo = params.anchorTo || this.anchorTo;

  this._addWindowEventListeners();
  this.isActive = true;
  this._moveContent();
  this._updatePosition(params);
  this._addClass(this.contentEl, 'animate');
  var e = document.createEvent('Event');
  e.initEvent('spark.visible-children', true, true);
  this.contentEl.dispatchEvent(e);
  this._updateAttributes();

  (params.complete || noop)();
  (this.onOpen || noop)();
},

close

method
 close() 

Close.

Option name Type Description
params Object

Optional

close: function(params) {

  params = params || {};

  // If there is a timer running for the close even, clear it so we don't run close stuff twice.
  if (this.closeTimer) {
    clearTimeout(this.closeTimer);
    this.closeTimer = null;
  }

  this._removeWindowEventListeners();
  this.isActive = false;
  this._updateAttributes();

  this.closeTimer = setTimeout(function() {
    this._clearPosition();
    this._moveContent();
    this._removeClass(this.contentEl, 'animate');
    (params.complete || noop)();
    (this.onClose || noop)();
  }.bind(this), 250);
},

toggle

method
 toggle() 

Toggle the open state.

toggle: function() {
  this[this.isActive ? 'close' : 'open']();
},

setContent

method
 setContent() 

Set the content. Optionally append instead of replacing.

Option name Type Description
content Element, Array, NodeList
params Object

Optional

setContent: function(content, params) {
  params = params || {};
  this._appendChildren(this.contentEl, content.length ? content : [content], !(params.append || false));
},

pause

method
 pause() 

Pause the popover. This stops is from positioning itself.

pause: function() {
  this.isPaused = true;
  this._clearPosition();
},

resume

method
 resume() 

Resume the popover positioning itself.

resume: function() {
  this.isPaused = false;
},

_cacheElements

method
 _cacheElements() 

Store a reference to the tabs list, each tab and each panel.
Set which tab is active, or use the first.

Option name Type Description
el Element
_cacheElements: function(el) {

  // If a content element was already passed, make sure it has a popover content class
  if (this.contentEl) {
    this._addClass(this.contentEl, 'spark-popover__content');
    this._addClass(this.contentEl, 'spark-popover__content--' + this.direction);
  }

  this.el = el;
  this.toggleEl = this.el.querySelector('.spark-popover__toggle, [data-role="toggle"]') || this.el;
  this.contentEl = this.contentEl || this.el.querySelector('[class*="spark-popover__content--"]') || this._createContentEl();
  this.caretEl = this.el.querySelector('.spark-popover__caret') || this._createCaretEl();
  this.isActive = this._hasClass(this.toggleEl, 'popover-active');

  //check if we're on an asp forms page
  var a = document.querySelectorAll('body > form');
  for (var i = 0; i < a.length; i++) {
    if (a[i].contains(this.el)) {
      this.targetForm = a[i];
      break;
    }
  }

  this._determineDirectionAndAnchor();
},

_moveContent

method
 _moveContent() 

Move the content node to be at the root of the page.

_moveContent: function() {
  if (this.targetForm) {
    if (this.isActive) {
      this.targetForm.appendChild(this.contentEl);
    } else if (this.contentEl.parentNode !== this.el && !this.isActive) {
      this.el.appendChild(this.contentEl);
    }
  } else {
    // Move the popover to the root of the body.
    if (this.isActive && (this.contentEl.parentNode !== document.body)) {
      document.body.appendChild(this.contentEl);
    } else if (this.contentEl.parentNode !== this.el && !this.isActive) {
      this.el.appendChild(this.contentEl);
    }
  }
},

_determineDirectionAndAnchor

method
 _determineDirectionAndAnchor() 

Determine the direction of of the popover.

_determineDirectionAndAnchor: function() {

  if (this.contentEl.className.indexOf('popover__content--left') !== -1) {
    return (this.direction = 'left');
  } else if (this.contentEl.className.indexOf('popover__content--right') !== -1) {
    return (this.direction = 'right');
  } else if (this.contentEl.className.indexOf('popover__content--top') !== -1) {
    return (this.direction = 'top');
  }

  this.defaultAnchorX = this.contentEl.getAttribute('data-anchor-x') || this.defaultAnchorX;
  this.defaultAnchorY = this.contentEl.getAttribute('data-anchor-y') || this.defaultAnchorY;
},

_updateAttributes

method
 _updateAttributes() 

Update classes for the open or close state.

_updateAttributes: function() {

  this._toggleClass(this.el, 'popover-active', this.isActive);
  this._toggleClass(this.contentEl, 'popover-active', this.isActive);
  this._toggleClass(this.toggleEl, 'active', this.isActive);
},

_updatePosition

method
 _updatePosition() 

Update the position of the popover.

_updatePosition: function() {

  if (!this.isActive) {
    return;
  }

  if (this.isPaused) {
    return this._clearPosition();
  }

  this._addClass(this.contentEl, 'measure');

  var toggleEl = this.anchorTo || this.toggleEl;
  var caretEl = this.caretEl;
  var toggleOffset = this._getElementOffset(toggleEl);
  var toggleHeight = toggleEl.offsetHeight;
  var toggleWidth = toggleEl.offsetWidth;
  var caretHeight = caretEl.offsetHeight;
  var caretWidth = caretEl.offsetWidth;
  var caretAdjustedWidth = Math.sqrt(Math.pow(caretWidth, 2) + Math.pow(caretHeight, 2)); // Accounts for the 90deg rotation in the CSS
  var contentHeight = this.contentEl.offsetHeight;
  var contentWidth = this.contentEl.offsetWidth;
  var docHeight = document.documentElement.scrollHeight;
  var docWidth = document.documentElement.scrollWidth;
  var xAxis = this.direction === 'bottom' || this.direction === 'top';
  var top = 0;
  var left = 0;
  var anchor = xAxis ? this.defaultAnchorX : this.defaultAnchorY;
  var anchorPos = xAxis ? (toggleWidth / 2) : (toggleHeight / 2);

  // Initial values
  if (this.direction === 'left') {
    top = toggleOffset.top - (contentHeight / 2) + (toggleHeight / 2);
    left = toggleOffset.left - contentWidth;
  } else if (this.direction === 'right') {
    top = toggleOffset.top - (contentHeight / 2) + (toggleHeight / 2);
    left = toggleOffset.left + toggleWidth;
  } else if (this.direction === 'top') {
    top = toggleOffset.top - contentHeight;
    left = toggleOffset.left - (contentWidth / 2) + (toggleWidth / 2);
  } else {
    top = toggleOffset.top + toggleHeight;
    left = toggleOffset.left - (contentWidth / 2) + (toggleWidth / 2);
  }

  // Check boundaries
  if (xAxis) {
    if (left + contentWidth > docWidth) {
      anchor = 'right';
      left = toggleOffset.left + toggleWidth - contentWidth;
      anchorPos = ((toggleWidth - caretAdjustedWidth) / 2 - caretWidth / 2);
    }
    if (left < 0) {
      anchor = 'left';
      left = 0;
      anchorPos = toggleOffset.left + ((toggleWidth - caretAdjustedWidth) / 2 + caretWidth / 2);
    }
  } else {
    if (top + contentHeight > docHeight) {
      anchor = 'bottom';
      anchorPos = anchorPos - (this.caretEl.offsetWidth * 1.5);
      top = toggleOffset.top;
    } else if (top < 0) {
      anchor = 'top';
      top = toggleOffset.top;
    }
  }

  this.contentEl.style.top = top + 'px';
  this.contentEl.style.left = left + 'px';
  this.contentEl.setAttribute('data-anchor', anchor);
  this._removeClass(this.contentEl, 'measure');

  this._updateCaretAnchorPosition({
    anchor: anchor,
    anchorPos: anchorPos
  });
},

_clearPosition

method
 _clearPosition() 

Clear position styling.

_clearPosition: function() {
  this.contentEl.removeAttribute('style');
  this.contentEl.removeAttribute('data-anchor-x');
  this.contentEl.removeAttribute('data-anchor-y');
  this.caretEl.removeAttribute('style');
},

_updateCaretAnchorPosition

method
 _updateCaretAnchorPosition() 

Update the position of the caret to be centered on the toggle element.

_updateCaretAnchorPosition: function(params) {

  if (!this.caretEl) {
    return;
  }

  this.caretEl.style.left = '';
  this.caretEl.style.right = '';
  this.caretEl.style.top = '';
  this.caretEl.style.bottom = '';
  this.caretEl.style[params.anchor] = params.anchorPos + 'px';
},

_bindEventListenerCallbacks

method
 _bindEventListenerCallbacks() 

Create bound versions of event listener callbacks and store them.
Otherwise we can't unbind from these events later because the
function signatures won't match.

_bindEventListenerCallbacks: function() {
  this._onClickBound = this._onClick.bind(this);
  this._onContentClickBound = this._onContentClick.bind(this);
  this._onWindowClickBound = this._onWindowClick.bind(this);
  this._onWindowResizeBound = this._onWindowResize.bind(this);
},

_addEventListeners

method
 _addEventListeners() 

Add event listeners for DOM events.

_addEventListeners: function() {
  this.el.addEventListener('click', this._onClickBound);
  this.contentEl.addEventListener('click', this._onContentClickBound);
},

_removeEventListeners

method
 _removeEventListeners() 

Remove event listeners for DOM events..

_removeEventListeners: function() {
  this.el.removeEventListener('click', this._onClickBound);
  this.contentEl.removeEventListener('click', this._onContentClickBound);
},

_addWindowEventListeners

method
 _addWindowEventListeners() 

Add event listeners to the window.

_addWindowEventListeners: function() {
  this._removeWindowEventListeners();
  window.addEventListener('click', this._onWindowClickBound);
  window.addEventListener('resize', this._onWindowResizeBound);
  window.addEventListener('orientationchange', this._onWindowResizeBound);
},

_removeWindowEventListeners

method
 _removeWindowEventListeners() 

Remove window event listeners.

_removeWindowEventListeners: function() {
  window.removeEventListener('click', this._onWindowClickBound);
  window.removeEventListener('resize', this._onWindowResizeBound);
  window.removeEventListener('orientationchange', this._onWindowResizeBound);
},

_createContentEl

method
 _createContentEl() 

Create a content element.

Option name Type Description
return Element
_createContentEl: function() {
  var el = document.createElement('div');
  this._addClass(el, 'spark-popover__content');
  this._addClass(el, 'spark-popover__content--' + this.direction);
  el.setAttribute('role', 'tooltip');
  return el;
},

_createCaretEl

method
 _createCaretEl() 

Create the caret element.

Option name Type Description
return Element
_createCaretEl: function() {
  var el = document.createElement('div');
  el.className = 'spark-popover__caret';
  this.contentEl.appendChild(el);
  return el;
},

_onClick

method
 _onClick() 

When we are clicked, toggle the popover-active state.

Option name Type Description
e Object
_onClick: function(e) {

  // If this is the toggle element, toggle.
  if (e.target === this.toggleEl || this._elementHasParent(e.target, this.toggleEl)) {
    e.preventDefault();
    this.toggle();
    return;
  }
},

_onContentClick

method
 _onContentClick() 

When the toggle is clicked, close if it's a link. If it's content, don't do anything but stop
the event from bubbling.

Option name Type Description
e Object
_onContentClick: function(e) {

  // If this is a link, close.
  if (this._getElementMatchingParent(e.target, '.spark-popover__list-link', this.contentEl) || this._getElementMatchingParent(e.target, '.spark-popover__close', this.contentEl)) {
    this.close();
    return;
  }
},

_onWindowClick

method
 _onWindowClick() 

When the window is clicked and it's not part of the popover, close the popover.

Option name Type Description
e Objec
_onWindowClick: function(e) {
  if (e.target !== this.el && !this._elementHasParent(e.target, this.el) && !this._elementHasParent(e.target, this.contentEl)) {
    this.close();
  }
},

_onWindowResize

method
 _onWindowResize() 

When the window is resized, ensure the proper position of the popover.

_onWindowResize: function() {
  this._updatePosition();
}
  };

  Base.exportjQuery(Popover, 'Popover');

  return Popover;
}));