Sabre Spark

Header

A primary page header/navigation.

Option name Type Description
module components/header.js
Example
new Header(el, {
  // Optional. Alternate breakpoint values.
  breakpoints: {
   xs: {
     min: 0,
     max: 639
   },
   // ...
 }
});

Header constructor.

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

  if (!el) {
    return;
  }

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

  // Determine the initial menu size now and when we get a ready state change
  this._determineInitialSize();
  this._listenForReadyStateChange();
};

Header.prototype = {

_setParams

property
 _setParams 

Include common functionality.

_setParams: Base.setParams,
_toggleClass: Base.toggleClass,
_addClass: Base.addClass,
_hasClass: Base.hasClass,
_removeClass: Base.removeClass,
_getChildIndex: Base.getChildIndex,
_elementHasParent: Base.elementHasParent,
_appendChildren: Base.appendChildren,
_insertBefore: Base.insertBefore,
_getBreakpoint: Base.getBreakpoint,
_getElementMatchingParent: Base.getElementMatchingParent,
_getElementMatchingParents: Base.getElementMatchingParents,
remove: Base.remove,

_whitelistedParams

property
 _whitelistedParams 

Whitelisted parameters which can be set on construction.

Option name Type Description
_whitelistedParams: ['breakpoints', 'fixed', 'fixedDistance'],

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,
  fixed: false,
  fixedDistance: 10,
  navEl: null,
  menuEl: null,
  listEl: null,
  listEls: null,
  listMoreEl: null,
  listMoreListEl: null,
  placeholder: null,
  toggleEl: null,
  lastBreakpoint: null,
  currentBreakpoint: null,
  isActive: false,
  isCollapsed: null,
  moreSwapIndex: -1,
  menu: null,
  breakpoints: null,
  _onResizeBound: null,
  _onScrollBound: null,
  _onMoreClickBound: null,
  _onToggleClickBound: null,
  _onNavClickBound: null
},

update

method
 update() 

Update the elements used.

update: function() {

  this._removeEventListeners();
  this._removePlaceholder();
  this._cacheElements(this.el);
  this._addEventListeners();
  this._ensureActiveAtMoreSwapIndex();
  this.checkFixed();

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

checkFixed

method
 checkFixed() 

Check of we should be fixed.

checkFixed: function() {

  if (!this.fixed) {
    return;
  }

  var scrollTop = window.pageYOffset !== undefined ? window.pageYOffset : window.document.body.scrollTop;
  var isCondensed = scrollTop > this.fixedDistance;
  this._toggleClass(this.el, 'spark-header--condensed', isCondensed);
  this._toggleClass(document.body, 'spark-header-condensed', isCondensed);
},

_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) {

  this.el = el;
  this.navEl = this.el.querySelector('.spark-header__nav');
  this.menuEl = this.navEl && this.navEl.querySelector('.spark-header__menu');
  this.listEl = this.menuEl && this.menuEl.querySelector('.spark-header__list, [data-role="list"]'); // @todo: remove data-role in v2.x.x
  this.toggleEl = this.el.querySelector('.spark-header__toggle');

  if (!this.fixed && this._hasClass(this.el, 'spark-header--fixed')) {
    this.fixed = true;
    this.checkFixed();
  }

  // Create a new instance of the menu component
  if (this.menuEl) {
    this.menu = new Menu(this.menuEl, {
      onToggle: this._onToggleClickBound
    });
  }

  // The items in the list need to show/hide based on the width of the container.
  // Cache these items so we can manipulate their display independent of what is
  // currently in the DOM. Also, create the "More" dropdown which will be shown
  // and hidden based on availabile space.
  if (this.listEl && this.listEl.children.length) {
    this.listEls = Array.prototype.slice.call(this.listEl.children, 0);
    this._createListMore();
  }

  // Create a clone of the header which will NOT be affected by changes in breakpoint.
  // This lets us continue to measure how many list elements will fit. Since we go to the
  // "condensed" view when we are at the sm/xs breakpoint OR only one item will fit in the nav,
  // we can't rely on breakpoints alone to determine what to show. Without a cloned placeholder
  // it is impossible to continue to measure the available space once we show the condensed view.
  if (this.listEl) {
    this._createPlaceholder();
  }
},

_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._onScrollBound = this._onScroll.bind(this);
  this._onMoreClickBound = this._onMoreClick.bind(this);
  this._onToggleClickBound = this._onToggleClick.bind(this);
  this._onNavClickBound = this._onNavClick.bind(this);
},

_addEventListeners

method
 _addEventListeners() 

Add event listeners for DOM events.

_addEventListeners: function() {

  if (!this.listEl) {
    return;
  }

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

  if (this.listMoreEl) {
    this.listMoreListEl.addEventListener('click', this._onMoreClickBound);
  }

  if (this.toggleEl) {
    this.toggleEl.addEventListener('click', this._onToggleClickBound);
  }

  if (this.navEl) {
    this.navEl.addEventListener('click', this._onNavClickBound);
  }

  if (this.fixed) {
    window.addEventListener('scroll', this._onScrollBound);
  }
},

_removeEventListeners

method
 _removeEventListeners() 

Remove event listeners for DOM events..

_removeEventListeners: function() {

  if (!this.listEl) {
    return;
  }

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

  if (this.listMoreEl) {
    this.listMoreListEl.removeEventListener('click', this._onMoreClickBound);
  }

  if (this.toggleEl) {
    this.toggleEl.removeEventListener('click', this._onToggleClickBound);
  }

  if (this.navEl) {
    this.navEl.removeEventListener('click', this._onNavClickBound);
  }

  if (this.fixed) {
    window.removeEventListener('scroll', this._onScrollBound);
  }
},

_getCurrentBreakpoint

method
 _getCurrentBreakpoint() 

Get the current breakpoint for the header.

_getCurrentBreakpoint: function() {
  this.lastBreakpoint = this.currentBreakpoint;
  this.currentBreakpoint = this._getBreakpoint(this.el.clientWidth, this.breakpoints);
  this.el.setAttribute('data-breakpoint', this.currentBreakpoint);
},

_createPlaceholder

method
 _createPlaceholder() 

Create a placeholder for the whole header so that we can keep track
of the width of each child element regardless of whether or not we're
condensed. Condensed styles do not apply to instances of the element
with the placeholder class.

_createPlaceholder: function() {

  var div = document.createElement('div');
  div.innerHTML = this.navEl.outerHTML;

  var el = div.children[0];
  el.setAttribute('aria-hidden', true);

  this._addClass(el, 'spark-header__placeholder');

  this._disablePlaceholderLinkTab(el);

  this.el.appendChild(el);

  // Cache the common elements
  this.placeholder = {
    el: el,
    menuEl: el.querySelector('.spark-header__menu'),
    listEl: el.querySelector('.spark-header__list, [data-role="list"]') // @todo: remove data-role in v2.x.x
  };

  // Add a copy of the "more" button to the list so we always know what size it would be
  if (this.listMoreEl) {
    this.placeholder.listEl.innerHTML += this.listMoreEl.outerHTML;
    this.placeholder.listMoreEl = this.placeholder.listEl.querySelector('.spark-header__more');
  }
},

_removePlaceholder

method
 _removePlaceholder() 

Remove the placeholder

_removePlaceholder: function() {

  if (this.placeholder) {
    this.placeholder.el.parentNode.removeChild(this.placeholder.el);
    this.placeholder.menuEl.parentNode.removeChild(this.placeholder.menuEl);
    this.placeholder.listEl.parentNode.removeChild(this.placeholder.listEl);
  }

  if (this.listMoreEl) {
    this.placeholder.listMoreEl.parentNode.removeChild(this.placeholder.listMoreEl);
  }
},

_disablePlaceholderLinkTab

method
 _disablePlaceholderLinkTab() 

Disable tabbing for items in the placeholder.

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

  // Set a negative tab index on each link in the placeholder
  var links = el.querySelectorAll('.spark-menu__list-link, .spark-menu__list-expand');
  var i = 0;
  var len = links.length;

  for (; i < len; i++) {
    links.item(i).setAttribute('tabindex', -1);
  }
},

_createListMore

method
 _createListMore() 

Create a place to store overflow items of the list.
Also add this item to the placeholder element so we always know
which size it would be.

_createListMore: function() {

  var div = document.createElement('div');
  div.innerHTML = '<li><a class="spark-menu__list-link spark-menu__ignore" title="More Items" tabindex="0"><i class="spark-icon-menu-ellipsis-horizontal spark-icon--fill"></i></a><ul class="spark-menu__list"></ul></li>';

  var li = div.children[0];
  this._addClass(li, 'spark-menu__list-item spark-header__more');

  this.listMoreEl = li;
  this.listMoreListEl = li.querySelector('ul');
},

_determineInitialSize

method
 _determineInitialSize() 

Determine the menu size.

_determineInitialSize: function() {
  this._addClass(this.el, 'spark-header--visible');
  this._ensureActiveAtMoreSwapIndex();
  this._determineMenuSize();
},

_determineMenuSize

method
 _determineMenuSize() 

Determine how many nav items can fit.

Option name Type Description
isSwap Boolean

Optional Is this a swapping event? If so, ignore redundancy checks.

_determineMenuSize: function(isSwap) {

  // Don't do anything w/o primary nav.
  if (!this.listEls || !this.listEls.length) {
    return;
  }

  // If we're at the XS or SM breakpoint, don't worry about this stuff.
  if (this._isMenuBreakpoint(['xs', 'sm'])) {
    this._removeListMore();
    return this._toggleCollapsed(true);
  }

  // Get the items to show and hide
  var items = this._getItemsToShowAndHide();

  // Add a class saying that the size has been determined. This removes the overflow:hidden
  // so that dropdowns will appear.
  this._addClass(this.el, 'spark-header--overflow-checked');

  // If there are less than two elements to show and we have hidden elements, collapse the nav.
  if (items.show.length < 2 && items.hide.length) {
    this._removeListMore();
    return this._toggleCollapsed(true);
  }

  // We aren't at the XS breakpoint and there aren't too few items to show, so disable collapsing
  this._toggleCollapsed(false);

  // If the number of children to hide is the same as those already hidden, stop.
  if (items.hide.length === this.listMoreListEl.children.length && !isSwap) {

    if (!items.hide.length) {
      this._removeListMore();
    }

    return;
  }

  // Add the elements we're supposed to show before the "more element"
  this._insertBefore(this.listEl, this.listMoreEl, items.show);

  // If we have items to hide, append them to the more element
  if (items.hide.length) {
    this._appendChildren(this.listMoreListEl, items.hide);
  }
  // Otherwise, remove the more element
  else {
    this._removeListMore();
  }
},

_listenForReadyStateChange

method
 _listenForReadyStateChange() 

Listen for the ready state change and rerun the menu size determination.

_listenForReadyStateChange: function() {

  // Already loaded
  if (document.readyState === 'complete' || document.readyState === 'loaded') {
    return;
  }

  // Bound listener
  var run = function() {
    if (document.readyState === 'complete' || document.readyState === 'loaded') {
      this._determineMenuSize();
      document.removeEventListener('readystatechange', run);
    }
  }.bind(this);

  // Only run once
  document.addEventListener('readystatechange', run);
},

_isMenuBreakpoint

method
 _isMenuBreakpoint() 

Check the primary nav breakpoint.

Option name Type Description
name String, Array

A string or array of string names of breakpoints to check for

_isMenuBreakpoint: function(name) {
  this._getCurrentBreakpoint();
  return name instanceof Array ? name.indexOf(this.currentBreakpoint) !== -1 : this.currentBreakpoint === 'xs';
},

_getItemsToShowAndHide

method
 _getItemsToShowAndHide() 

Get the items to show and hide.

Option name Type Description
return Object
_getItemsToShowAndHide: function() {

  var width = this.placeholder.listEl.clientWidth;
  var children = this.placeholder.listEl.children;
  var i = 0;
  var len = children.length;
  var hideIndex = -1;

  this._addListMore();

  // Always include the width of the more button.
  var childrenWidth = this.placeholder.listMoreEl.clientWidth || 0;

  // Loop through the children until we hit a point where they don't fit anymore
  for (; i < len && hideIndex === -1; i++) {
    childrenWidth += children[i].clientWidth;
    if (childrenWidth > width) {
      hideIndex = i;
    }
  }

  // Find all the children that fit and don't fit
  var items = {
    show: hideIndex !== -1 ? Array.prototype.slice.call(this.listEls, 0, hideIndex) : this.listEls,
    hide: hideIndex !== -1 ? Array.prototype.slice.call(this.listEls, hideIndex) : []
  };

  // If we have an index to swap for the last "show" element, replace that element
  if (this.moreSwapIndex > -1 && this.moreSwapIndex >= items.show.length) {

    // Remove the last element from the show array
    var toHide = items.show.splice(items.show.length - 1, 1)[0];

    // Get the index to remove from the hide array. Account for the offset.
    var toShowIndex = this.moreSwapIndex - hideIndex;

    // Remove the desired element from the hide array
    var toShow = items.hide.splice(toShowIndex, 1)[0];

    // Add the toShow element to the end of the show array
    items.show.push(toShow);

    // Insert the toHide element into the hide array at the position of
    // the element we just removed from the hide array.
    items.hide.splice(toShowIndex, 0, toHide);
  }

  return items;
},

_ensureActiveAtMoreSwapIndex

method
 _ensureActiveAtMoreSwapIndex() 

Ensure that any active item is set to the more swap index. This ensures
that the active item is always visible on the screen.

_ensureActiveAtMoreSwapIndex: function() {

  if (!this.listEls || !this.listEls.length) {
    return;
  }

  var el = this.el.querySelector('[class*="list-item"].active');
  if (el) {
    var parents = this._getElementMatchingParents(el, '.spark-menu__list-item', this.el);

    if (parents && parents[parents.length - 1]) {
      el = parents[parents.length - 1];
    }

    var index = this._getChildIndex(this.listEls, el);

    if (index !== this.moreSwapIndex) {
      this.moreSwapIndex = index;
    }
  }
},

_addListMore

method
 _addListMore() 

Add a placeholder for overflow items to the list.

_addListMore: function() {
  if (this.listMoreEl.parentNode !== this.listEl) {
    this.listEl.appendChild(this.listMoreEl);
  }
},

_removeListMore

method
 _removeListMore() 

Remove a placeholder for overflow items from the primary nav.

_removeListMore: function() {
  if (this.listMoreEl.parentNode) {
    this.listMoreEl.parentNode.removeChild(this.listMoreEl);
  }
},

_resetMenuChildren

method
 _resetMenuChildren() 

Reset the children of the primary navigation.

_resetMenuChildren: function() {
  this.moreSwapIndex = -1;
  this._removeClass(this.el, 'spark-header--overflow-checked');
  this._appendChildren(this.listEl, this.listEls);
},

_toggleCollapsed

method
 _toggleCollapsed() 

Toggle the collapsed nav style.

Option name Type Description
enable Boolean
_toggleCollapsed: function(enable) {

  // Same collapsed state is already set
  if (enable === this.isCollapsed) {
    return;
  }

  // Reset children and remove a special no-animate class to top-level items when we collapse
  if (enable) {
    this._enableTopLevelToggling();
    this._resetMenuChildren();
  } else {
    if (this.menu) {
      this.menu._removeAllCachedLists();
    }
    this._disableTopLevelToggling();
  }

  this.isCollapsed = enable;
  this._toggleClass(this.el, 'spark-header--collapsed', enable);
  this._toggleClass(this.el, 'spark-header--visible', !enable);
},

_enableTopLevelToggling

method
 _enableTopLevelToggling() 

Enable toggling on top-level items.

_enableTopLevelToggling: function() {

  var i = 0;
  var len = this.listEls.length;

  for (; i < len; i++) {
    this._removeClass(this.listEls[i], 'spark-no-animate');
  }
},

_disableTopLevelToggling

method
 _disableTopLevelToggling() 

Disable toggling on top-level items.

_disableTopLevelToggling: function() {

  var i = 0;
  var len = this.listEls.length;

  for (; i < len; i++) {
    this._addClass(this.listEls[i], 'spark-no-animate');
  }
},

_onResize

method
 _onResize() 

When the window resizes, redetermine the size of the primary nav elements.

_onResize: function() {

  // Ensure that any active item we may have is at the swap index
  this._ensureActiveAtMoreSwapIndex();
  this._determineMenuSize();

  // If we are fixed, do the scroll check
  if (this.fixed) {
    this.checkFixed();
  }
},

_onScroll

method
 _onScroll() 

Check to see if the header should be fixed.

Option name Type Description
e Object
_onScroll: function() {
  this.checkFixed();
},

_onMoreClick

method
 _onMoreClick() 

When a link in the more list is clicked, swap it with the last element in the visible list.

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

  // Don't do any swapping if we're in a collapsed state
  if (this.isCollapsed) {
    return;
  }

  // Get the index of the clicked element
  var li = this._getElementMatchingParent(e.target, 'li', this.listMoreListEl);

  // Save the index of the element to be swapped
  this.moreSwapIndex = this._getChildIndex(this.listEls, li);

  // Redetermine the primary nav size
  this._determineMenuSize(true);
},

_onToggleClick

method
 _onToggleClick() 

When the toggle is clicked, toggle the active state on the nav

Option name Type Description
e Object
_onToggleClick: function(e) {
  e.preventDefault();
  this.isActive = !this.isActive;
  this._toggleClass(this.navEl, 'active', this.isActive);
  this.menu._openActiveParents();
},

_onNavClick

method
 _onNavClick() 

When the nav is clicked, set to inactive.

Option name Type Description
e Object
_onNavClick: function(e) {
  if (e.target === this.navEl && this.isCollapsed) {
    this.isActive = !this.isActive;
    this._removeClass(this.navEl, 'active');
  }
}
  };

  Base.exportjQuery(Header, 'Header');

  return Header;
}));