Tabs

function
 Tabs() 

Option name Type Description
el Element
params Object

Tabs constructor.

var Tabs = function(el, params) {

  if (!el) {
    return;
  }

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

Tabs.prototype = {

_setParams

property
 _setParams 

Include common functionality.

_setParams: Base.setParams,
_toggleClass: Base.toggleClass,
_addClass: Base.addClass,
_removeClass: Base.removeClass,
_getElementMatchingParent: Base.getElementMatchingParent,
_getChildIndex: Base.getChildIndex,
_elementHasParent: Base.elementHasParent,
_getElementMatchingChildren: Base.getElementMatchingChildren,
_getBreakpoint: Base.getBreakpoint,

_whitelistedParams

property
 _whitelistedParams 

Whitelisted parameters which can be set on construction.

_whitelistedParams: ['useHash', 'breakpoints'],

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.

defaults: {
  el: null,
  tabListEl: null,
  tabListScrollEl: null,
  tabEls: null,
  panelEls: null,
  activeTabEl: null,
  activePanelEl: null,
  navEl: null,
  leftEl: null,
  rightEl: null,
  useHash: false,
  isScrollable: false,
  isDragging: false,
  isAnimatable: false,
  scrollDirection: '',
  scrollDistance: 0,
  lastX: 0,
  lastY: 0,
  minX: 0,
  maxX: 0,
  x: 0,
  _onFocusBound: null,
  _onBlurBound: null,
  _onTabListClickBound: null,
  _onLeftClickBound: null,
  _onRightClickBound: null,
  _onResizeBound: null,
  _onTouchStartBound: null,
  _onTouchMoveBound: null,
  _onTouchEndBound: null,
  _onMouseDownBound: null,
  _onMouseMoveBound: null,
  _onMouseUpBound: null,
  _onScrollBound: null
},

setActive

method
 setActive() 

Option name Type Description
el String, Number, Object

Set the active item.

setActive: function(el) {

  var panel;

  // If we're passed a string instead of an element or number,
  // get the panel with that id.
  if (typeof el === 'string') {
    panel = this._findPanelByName(el);

    // If we've found a panel, find the corresponding tab.
    if (panel) {
      el = this._findTabByPanel(panel);
    }
  }

  // If we're passed a number instead of an element,
  // get that item from the tabEls NodeList
  if (typeof el === 'number') {
    el = this.tabEls.item(el);
  }

  // If we couldn't find the element or it's already active, stop.
  if (!el || typeof el !== 'object' || el === this.activeTabEl) {
    return;
  }

  // Remove the active class from the currently active tab
  if (this.activeTabEl) {
    this._toggleClass(this.activeTabEl, 'active', false);
    this.previousTabEl = this.activeTabEl;
  }

  // Add the active class and store.
  this._toggleClass(el, 'active', true);
  this.activeTabEl = el;

  // Focus the tab on the left side if it's to the left of the frame.
  if (-el.offsetLeft > this.x) {
    this.focus(el, 'left');
  }
  // Focus the tab on the right side if it's to the right of the frame.
  else if (el.offsetLeft + el.clientWidth > this.tabListScrollEl.clientWidth - this.x) {
    this.focus(el, 'right');
  }

  // If we don't already have a panel, find the panel that corresponds to this tab.
  if (!panel) {
    panel = this._findPanelByTab(el);
  }

  // Set the new panel to be active.
  this._toggleClass(panel, 'active', true);

  // Remove the active class from the currently active panel.
  if (this.activePanelEl) {
    this._toggleClass(this.activePanelEl, 'active', false);
  }

  // Store the new active panel
  this.activePanelEl = panel;

  // Set the hash
  if (this.useHash) {
    window.location.hash = this.activePanelEl.getAttribute('id') || '';
  }
  var e = document.createEvent('Event');
  e.initEvent('spark.visible-children', true, true);
  this.activePanelEl.dispatchEvent(e);
},

start

method
 start() 

Option name Type Description
params Object

Start the drag

start: function(params) {

  params = params || {};

  // Start dragging
  this.isDragging = true;

  // Stash the element and its position
  this.lastX = params.lastX;
  this.lastY = params.lastY;

  // Stash the min and max values
  this._determineMinMax();

  // Add listeners to the body so we can drag this thing anywhere and still get events
  this._addMoveEventListeners(params.type || 'mouse');
},

stop

method
 stop() 

Option name Type Description
params Object

Stop the drag

stop: function(params) {

  params = params || {};

  // Make sure we're in bounds
  this._checkX();

  // Stop dragging
  this.isDragging = false;
  this.scrollDistance = 0;

  // Reset the scroll direction
  this.scrollDirection = '';

  // Unbind event listeners on the body
  this._removeMoveEventListeners(params.type);
},

move

method
 move() 

Option name Type Description
params Object

Move the drag point

move: function(params) {

  // Make sure we're currently dragging
  if (!this.isDragging && !params.scroll && !params.force) {
    return;
  }

  // If we're beyond the bounds, add some resistance to the scroll.
  if (!params.force && (this.x + params.x > this.maxX || this.x + params.x < this.minX)) {
    this.x += params.x / 4;
  } else {
    this.x += params.x;
  }

  this.scrollDistance += Math.abs(params.x);

  if (params.scroll) {
    this._checkX();
  }

  this._updatePosition();
},

focus

method
 focus() 

Option name Type Description
el Element
align String

Which side to align with.

Focus on a specific element by bringing it to the middle of the scroller.

focus: function(el, align) {

  align = align || 'left';

  this.x = align === 'left' ? -el.offsetLeft : -(el.offsetLeft - this.tabListScrollEl.clientWidth + el.offsetWidth);

  this._checkX();
  this._updatePosition();
},

remove

method
 remove() 

Option name Type Description
leaveElement Boolean

Leave the element intact.

Remove the element from the DOM and prepare for garbage collection by dereferencing values.

remove: function(leaveElement) {
  this._removeMoveEventListeners('touch');
  this._removeMoveEventListeners('mouse');
  this._removeMoveEventListeners('keyboard');
  Base.remove.call(this, leaveElement);
},

update

method
 update() 

Update the elements used.

update: function() {

  this._removeEventListeners();
  this._cacheElements(this.el);
  this._addEventListeners();

  // Run on the next frame so sizes have updated
  setTimeout(function() {
    this._determineSize();
    this.focus(this.activeTabEl);
  }.bind(this), 0);
},

_findPanelByName

method
 _findPanelByName() 

Option name Type Description
name String
return Object, Null

Find a panel element by name.

_findPanelByName: function(name) {

  name = name.replace('#', '');

  var i = 0;
  var len = this.panelEls.length;
  var found = null;
  var el;

  for (; i < len && !found; i++) {
    if (this.panelEls[i].getAttribute('id') === name) {
      el = this.panelEls[i];
      found = true;
    }
  }

  return found && el;
},

_findPanelByTab

method
 _findPanelByTab() 

Option name Type Description
tab Object
return Object, Null

Find a panel given its corresponding tab. Try to match based on the
id attribute, but fall back to matching based on index.

_findPanelByTab: function(tab) {

  var anchorChild = tab.querySelector('a');
  var id = anchorChild && anchorChild.getAttribute('href');
  var index = this._getChildIndex(tab.parentNode.children, tab);
  var i = 0;
  var len = this.panelEls.length;
  var foundById = null;
  var idMatch = null;
  var indexMatch = null;

  id = id ? id.replace('#', '') : id;

  for (; i < len && !foundById; i++) {
    if (id && this.panelEls[i].getAttribute('id') === id) {
      foundById = true;
      idMatch = this.panelEls[i];
    } else if (i === index) {
      indexMatch = this.panelEls[i];
    }
  }

  return (foundById && idMatch) || indexMatch;
},

_findTabByPanel

method
 _findTabByPanel() 

Option name Type Description
panel Object
return Object, Null

Find a tab given its corresponding panel. Try to match based on the
[href] attribute, but fall back to matching based on index.

_findTabByPanel: function(panel) {

  var id = panel.getAttribute('id');
  var index = this._getChildIndex(panel.parentNode.children, panel);
  var i = 0;
  var len = this.tabEls.length;
  var foundById = null;
  var idMatch = null;
  var indexMatch = null;

  for (; i < len && !foundById; i++) {
    if (id && (this.tabEls.item(i).querySelector('a').getAttribute('href') === ('#' + id) || this.tabEls.item(i).getAttribute('href') === ('#' + id))) {
      foundById = true;
      idMatch = this.tabEls.item(i);
    } else if (i === index) {
      indexMatch = this.tabEls.item(i);
    }
  }

  return (foundById && idMatch) || indexMatch;
},

_findTabByChildElement

method
 _findTabByChildElement() 

Option name Type Description
el Element
return Object

Find the tab which an element lives inside.

_findTabByChildElement: function(el) {

  var i = 0;
  var len = this.tabEls.length;
  var found;
  var tab;

  for (; i < len && !found; i++) {

    // There is a chance that the element passed IS a tab. Or maybe a tab is its parent.
    if (this.tabEls.item(i) === el || this._elementHasParent(el, this.tabEls.item(i))) {
      found = true;
      tab = this.tabEls.item(i);
    }
  }

  return found && tab;
},

_cacheElements

method
 _cacheElements() 

Option name Type Description
el Element

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

_cacheElements: function(el) {

  this.el = el;
  this.tabListEl = this.el.querySelector('.spark-tabs__list');
  this.tabListScrollEl = this.tabListEl.parentNode;
  this.tabEls = this.tabListEl.querySelectorAll('.spark-tabs__tab');
  this.panelEls = this._getElementMatchingChildren(this.el.querySelector('.spark-tabs__panels'), '[role="tabpanel"]');
  this.navEl = this.el.querySelector('.spark-tabs__nav');
  this.leftEl = this.navEl.querySelector('.spark-tabs__btn--left');
  this.rightEl = this.navEl.querySelector('.spark-tabs__btn--right');

  // Make sure we have the elements we need
  if (!this.tabListEl || !this.tabEls.length || !this.panelEls.length) {
    throw new Error('Tab element missing either a .spark-tabs__list, or elements with .spark-tabs__tab and .spark-tabs__panel!', this.el);
  }

  // If there is a hash set, use that to try and set the active panel
  var hasSet = window.location.hash && this.setActive(window.location.hash);

  // If we weren't able to set with a hash, find the tab marked active or default to the first tab
  if (!hasSet) {
    this.setActive(this.tabListEl.querySelector('.spark-tabs__tab.active') || 0);
  }
},

_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._onResizeBound = this._onResize.bind(this);

  this._onTabListClickBound = this._onTabListClick.bind(this);

  this._onRightClickBound = this._onRightClick.bind(this);
  this._onLeftClickBound = this._onLeftClick.bind(this);

  this._onTouchStartBound = this._onTouchStart.bind(this);
  this._onTouchMoveBound = this._onTouchMove.bind(this);
  this._onTouchEndBound = this._onTouchEnd.bind(this);

  this._onMouseDownBound = this._onMouseDown.bind(this);
  this._onMouseMoveBound = this._onMouseMove.bind(this);
  this._onMouseUpBound = this._onMouseUp.bind(this);

  this._onScrollBound = this._onScroll.bind(this);

  this._onFocusBound = this._onFocus.bind(this);
  this._onBlurBound = this._onBlur.bind(this);
},

_addEventListeners

method
 _addEventListeners() 

Add event listeners for DOM events.

_addEventListeners: function() {

  window.addEventListener('resize', this._onResizeBound);
  window.addEventListener('orientationchange', this._onResizeBound);

  this.tabListEl.addEventListener('click', this._onTabListClickBound);

  this.tabListEl.addEventListener('touchstart', this._onTouchStartBound);
  this.tabListEl.addEventListener('mousedown', this._onMouseDownBound);
  this.tabListEl.addEventListener('mousewheel', this._onScrollBound);
  this.tabListEl.addEventListener('DOMMouseScroll', this._onScrollBound);

  this.tabListEl.addEventListener('focus', this._onFocusBound, true);
  this.tabListEl.addEventListener('blur', this._onBlurBound, true);

  if (this.leftEl) {
    this.leftEl.addEventListener('click', this._onLeftClickBound);
  }

  if (this.rightEl) {
    this.rightEl.addEventListener('click', this._onRightClickBound);
  }
},

_removeEventListeners

method
 _removeEventListeners() 

Remove event listeners for DOM events..

_removeEventListeners: function() {

  window.removeEventListener('resize', this._onResizeBound);
  window.removeEventListener('orientationchange', this._onResizeBound);

  this.tabListEl.removeEventListener('click', this._onTabListClickBound);

  this.tabListEl.removeEventListener('touchstart', this._onTouchStartBound);
  this.tabListEl.removeEventListener('mousedown', this._onMouseDownBound);
  this.tabListEl.removeEventListener('mousewheel', this._onScrollBound);
  this.tabListEl.removeEventListener('DOMMouseScroll', this._onScrollBound);

  this.tabListEl.removeEventListener('focus', this._onFocusBound);
  this.tabListEl.removeEventListener('blur', this._onBlurBound);

  if (this.leftEl) {
    this.leftEl.removeEventListener('click', this._onLeftClickBound);
  }

  if (this.rightEl) {
    this.rightEl.removeEventListener('click', this._onRightClickBound);
  }
},

_addMoveEventListeners

method
 _addMoveEventListeners() 

Option name Type Description
type String

Which type of listeners to add

Add event listeners for touchmove, touchend, mousemove and mouseup.
We add these to the window so that the user can move off of the element
but keep dragging the tabs.

_addMoveEventListeners: function(type) {

  // Only listen for events of the type we asked for.
  switch (type) {
    case 'mouse':
      window.addEventListener('mousemove', this._onMouseMoveBound);
      window.addEventListener('mouseup', this._onMouseUpBound);
      break;
    case 'touch':
      window.addEventListener('touchmove', this._onTouchMoveBound);
      window.addEventListener('touchend', this._onTouchEndBound);
      break;
  }
},

_removeMoveEventListeners

method
 _removeMoveEventListeners() 

Option name Type Description
type String

Which type of listeners to remove

Remove event listeners for move events.

_removeMoveEventListeners: function(type) {

  // Only unbind events of the type we asked for.
  switch (type) {
    case 'mouse':
      window.removeEventListener('mousemove', this._onMouseMoveBound);
      window.removeEventListener('mouseup', this._onMouseUpBound);
      break;
    case 'touch':
      window.removeEventListener('touchmove', this._onTouchMoveBound);
      window.removeEventListener('touchend', this._onTouchEndBound);
      break;
  }
},

_determineSize

method
 _determineSize() 

Determine which size class to set on the element. This is a way of using breakpoint-like
logic for the tabs. We can't rely on real breakpoints because there is no guarantee that
the tabs will be the width of the window.
Also determine if we should be showing navigation arrows.

_determineSize: function() {

  var width = this.el.clientWidth;
  var breakpoint = this._getBreakpoint(width, this.breakpoints);

  // If the found breakpoint is different than the current breakpoint, set the proper state.
  if (this.currentBreakpoint !== breakpoint) {
    this._toggleClass(this.el, this.currentBreakpoint, false);
    this.currentBreakpoint = breakpoint;
    this._toggleClass(this.el, this.currentBreakpoint, true);
  }

  // If the tab list is wider than the scroll container, set the scrollable class.
  this.isScrollable = this.tabListEl.clientWidth > this.tabListScrollEl.clientWidth;
  this._toggleClass(this.navEl, 'scrollable', this.isScrollable);
  this._determineMinMax();
},

_determineMinMax

method
 _determineMinMax() 

Determine the min and max values for the slider.

_determineMinMax: function() {

  if (!this.tabListEl || !this.tabListScrollEl) {
    return;
  }

  this.maxX = 0;
  this.minX = this.tabListScrollEl.clientWidth - this.tabListEl.clientWidth - this.maxX;
},

_enableAnimation

method
 _enableAnimation() 

Enable the animation state.

_enableAnimation: function() {
  this.isAnimatable = true;
  this._toggleClass(this.navEl, 'no-animation', !this.isAnimatable);
},

_disableAnimation

method
 _disableAnimation() 

Disable the animation state.

_disableAnimation: function() {
  this.isAnimatable = false;
  this._toggleClass(this.navEl, 'no-animation', !this.isAnimatable);
},

_updatePosition

method
 _updatePosition() 

Update the position of the tabs.

_updatePosition: function() {
  this.tabListEl.setAttribute('style', transform('translate', this.x + 'px'));
},

_checkX

method
 _checkX() 

Check the x position

_checkX: function() {

  if (this.x < this.minX) {
    this.x = this.minX;
    this._updatePosition();
  }

  if (this.x > 0) {
    this.x = 0;
    this._updatePosition();
  }
},

_onTabListClick

method
 _onTabListClick() 

Option name Type Description
e Object

When the user clicks on a tab, make it active.

_onTabListClick: function(e) {

  // Make sure we haven't scrolled.
  if (this.scrollDistance > 5) {
    e.preventDefault();
    return;
  }

  var tab;

  // Find if one of our tab elements is in the path
  if ((tab = this._findTabByChildElement(e.target))) {
    e.preventDefault();
    this.setActive(tab);
  }
},

_onResize

method
 _onResize() 

Option name Type Description
e Object

When the window resizes, determine the size we should be using for tabs.

_onResize: function() {
  this._determineSize();
  this.focus(this.activeTabEl);
},

_onTouchStart

method
 _onTouchStart() 

Option name Type Description
e Object

When the touchstart event fires, start the scrolling process

_onTouchStart: function(e) {

  if (!this.isScrollable) {
    return;
  }

  // Disable the animation class so we scroll smoothly
  this._disableAnimation();

  this.start({
    lastX: e.touches[0].clientX,
    lastY: e.touches[0].clientY,
    type: 'touch'
  });
},

_onTouchMove

method
 _onTouchMove() 

Option name Type Description
e Object

As the user continues moving the touch, determine
if we should move.

_onTouchMove: function(e) {

  var xDistance = e.touches[0].clientX - this.lastX;
  var yDistance = e.touches[0].clientY - this.lastY;

  // If we haven't yet determined a scroll direction
  if (!this.scrollDirection) {

    // Moving up and down
    if (Math.abs(yDistance) > Math.abs(xDistance)) {
      this.scrollDirection = 'ns';
    }
    // Moving side to side
    else {
      this.scrollDirection = 'ew';
    }
  }

  // If We're moving left to right, start the move.
  if (this.scrollDirection === 'ew') {

    e.preventDefault();

    this.move({
      x: xDistance
    });
  }

  this.lastX = e.touches[0].clientX;
  this.lastY = e.touches[0].clientY;
},

_onTouchEnd

method
 _onTouchEnd() 

Option name Type Description
e Object

When the touch is over.

_onTouchEnd: function() {

  // Enable the animation class
  this._enableAnimation();

  // Stop after one frame so that animation is fully reenabled
  window.setTimeout(function() {
    this.stop({
      type: 'touch'
    });
  }.bind(this), 1);
},

_onMouseDown

method
 _onMouseDown() 

Option name Type Description
e Object

When the mousedown event fires, start the scrolling process

_onMouseDown: function(e) {

  if (!this.isScrollable) {
    return;
  }

  // Disable the animation class so we scroll smoothly
  this._disableAnimation();

  this.start({
    lastX: e.clientX,
    lastY: e.clientY,
    type: 'mouse'
  });
},

_onMouseMove

method
 _onMouseMove() 

Option name Type Description
e Object

As the user continues moving the mouse, determine
if we should move.

_onMouseMove: function(e) {


  var xDistance = e.clientX - this.lastX;
  var yDistance = e.clientY - this.lastY;

  // If we haven't yet determined a scroll direction
  if (!this.scrollDirection) {

    // Moving up and down
    if (Math.abs(yDistance) > Math.abs(xDistance)) {
      this.scrollDirection = 'ns';
    }
    // Moving side to side
    else {
      this.scrollDirection = 'ew';
    }
  }

  // If We're moving left to right, start the move.
  if (this.scrollDirection === 'ew') {

    e.preventDefault();

    this.move({
      x: xDistance
    });
  }

  this.lastX = e.clientX;
  this.lastY = e.clientY;
},

_onMouseUp

method
 _onMouseUp() 

Option name Type Description
e Object

When the mouse move is complete.

_onMouseUp: function() {

  // If we haven't been dragging, get outta here!
  if (!this.isDragging) {
    return;
  }

  // Enable the animation class
  this._enableAnimation();

  // Stop after one frame so that animation is fully reenabled
  window.setTimeout(function() {
    this.stop({
      type: 'mouse'
    });
  }.bind(this), 1);
},

_onScroll

method
 _onScroll() 

Option name Type Description
e Object

When the user scrolls horizontally on the tabs, slide.

_onScroll: function(e) {

  // Don't bother if we aren't scrollable
  if (!this.isScrollable) {
    return;
  }

  // Disable the animation class so we scroll smoothly
  this._disableAnimation();

  // Allow for Firefox's wheel detail
  var val = e.wheelDeltaX || (-e.detail * 40);

  // If the scroll has moved...
  if (val) {

    // Supress native
    e.preventDefault();

    // Move us to the new position
    this.move({
      x: val,
      scroll: true
    });
  }

  // Cancel an existing scroll timer
  if (this.scrollTimer) {
    window.clearTimeout(this.scrollTimer);
    this.scrollTimer = null;
  }

  // The scroll is considered "done" after 100ms
  this.scrollTimer = window.setTimeout(this._onScrollEnd.bind(this), 100);
},

_onScrollEnd

method
 _onScrollEnd() 

When the scrolling ends, reset the scrollTop

_onScrollEnd: function() {

  // Enable the animation class
  this._enableAnimation();

  // Stop after one frame so that animation is fully reenabled
  window.setTimeout(function() {
    this.stop({
      type: 'scroll'
    });
  }.bind(this), 1);
},

_onLeftClick

method
 _onLeftClick() 

When the left button is clicked, slide the tabs to the right.

_onLeftClick: function() {
  this.move({
    x: this.tabListScrollEl.clientWidth,
    force: true
  });
  this.stop({
    type: 'force'
  });
},

_onRightClick

method
 _onRightClick() 

When the right button is clicked, slide the tabs to the left.

_onRightClick: function() {
  this.move({
    x: -this.tabListScrollEl.clientWidth,
    force: true
  });
  this.stop({
    type: 'force'
  });
},

_onFocus

method
 _onFocus() 

Option name Type Description
e Object

When focus is gained on a tab.

_onFocus: function(e) {
  var target = e.target || e.srcElement;
  var parent = this._getElementMatchingParent(target, '.spark-tabs__tab', this.tabListEl);
  if (parent) this._addClass(parent, 'focus');
},

_onBlur

method
 _onBlur() 

Option name Type Description
e Object

When focus is lost on a tab.

_onBlur: function(e) {
  var target = e.target || e.srcElement;
  var parent = this._getElementMatchingParent(target, '.spark-tabs__tab', this.tabListEl);
  if (parent) this._removeClass(parent, 'focus');
}
  };

  Base.exportjQuery(Tabs, 'Tabs');

  return Tabs;
}));