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 = {
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,
Whitelisted parameters which can be set on construction.
_whitelistedParams: ['useHash', 'breakpoints'],
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
},
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);
},
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');
},
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);
},
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();
},
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();
},
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 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);
},
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;
},
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;
},
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;
},
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;
},
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);
}
},
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);
},
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);
}
},
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);
}
},
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;
}
},
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;
}
},
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();
},
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;
},
Enable the animation state.
_enableAnimation: function() {
this.isAnimatable = true;
this._toggleClass(this.navEl, 'no-animation', !this.isAnimatable);
},
Disable the animation state.
_disableAnimation: function() {
this.isAnimatable = false;
this._toggleClass(this.navEl, 'no-animation', !this.isAnimatable);
},
Update the position of the tabs.
_updatePosition: function() {
this.tabListEl.setAttribute('style', transform('translate', this.x + 'px'));
},
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();
}
},
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);
}
},
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);
},
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'
});
},
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;
},
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);
},
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'
});
},
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;
},
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);
},
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);
},
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);
},
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'
});
},
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'
});
},
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');
},
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;
}));