562 lines
17 KiB
JavaScript
562 lines
17 KiB
JavaScript
|
'use strict';
|
||
|
|
||
|
import $ from 'jquery';
|
||
|
import { onLoad, transitionend, RegExpEscape } from './foundation.core.utils';
|
||
|
import { Keyboard } from './foundation.util.keyboard';
|
||
|
import { MediaQuery } from './foundation.util.mediaQuery';
|
||
|
import { Plugin } from './foundation.core.plugin';
|
||
|
|
||
|
import { Triggers } from './foundation.util.triggers';
|
||
|
|
||
|
/**
|
||
|
* OffCanvas module.
|
||
|
* @module foundation.offcanvas
|
||
|
* @requires foundation.util.keyboard
|
||
|
* @requires foundation.util.mediaQuery
|
||
|
* @requires foundation.util.triggers
|
||
|
*/
|
||
|
|
||
|
class OffCanvas extends Plugin {
|
||
|
/**
|
||
|
* Creates a new instance of an off-canvas wrapper.
|
||
|
* @class
|
||
|
* @name OffCanvas
|
||
|
* @fires OffCanvas#init
|
||
|
* @param {Object} element - jQuery object to initialize.
|
||
|
* @param {Object} options - Overrides to the default plugin settings.
|
||
|
*/
|
||
|
_setup(element, options) {
|
||
|
this.className = 'OffCanvas'; // ie9 back compat
|
||
|
this.$element = element;
|
||
|
this.options = $.extend({}, OffCanvas.defaults, this.$element.data(), options);
|
||
|
this.contentClasses = { base: [], reveal: [] };
|
||
|
this.$lastTrigger = $();
|
||
|
this.$triggers = $();
|
||
|
this.position = 'left';
|
||
|
this.$content = $();
|
||
|
this.nested = !!(this.options.nested);
|
||
|
|
||
|
// Defines the CSS transition/position classes of the off-canvas content container.
|
||
|
$(['push', 'overlap']).each((index, val) => {
|
||
|
this.contentClasses.base.push('has-transition-'+val);
|
||
|
});
|
||
|
$(['left', 'right', 'top', 'bottom']).each((index, val) => {
|
||
|
this.contentClasses.base.push('has-position-'+val);
|
||
|
this.contentClasses.reveal.push('has-reveal-'+val);
|
||
|
});
|
||
|
|
||
|
// Triggers init is idempotent, just need to make sure it is initialized
|
||
|
Triggers.init($);
|
||
|
MediaQuery._init();
|
||
|
|
||
|
this._init();
|
||
|
this._events();
|
||
|
|
||
|
Keyboard.register('OffCanvas', {
|
||
|
'ESCAPE': 'close'
|
||
|
});
|
||
|
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Initializes the off-canvas wrapper by adding the exit overlay (if needed).
|
||
|
* @function
|
||
|
* @private
|
||
|
*/
|
||
|
_init() {
|
||
|
var id = this.$element.attr('id');
|
||
|
|
||
|
this.$element.attr('aria-hidden', 'true');
|
||
|
|
||
|
// Find off-canvas content, either by ID (if specified), by siblings or by closest selector (fallback)
|
||
|
if (this.options.contentId) {
|
||
|
this.$content = $('#'+this.options.contentId);
|
||
|
} else if (this.$element.siblings('[data-off-canvas-content]').length) {
|
||
|
this.$content = this.$element.siblings('[data-off-canvas-content]').first();
|
||
|
} else {
|
||
|
this.$content = this.$element.closest('[data-off-canvas-content]').first();
|
||
|
}
|
||
|
|
||
|
if (!this.options.contentId) {
|
||
|
// Assume that the off-canvas element is nested if it isn't a sibling of the content
|
||
|
this.nested = this.$element.siblings('[data-off-canvas-content]').length === 0;
|
||
|
|
||
|
} else if (this.options.contentId && this.options.nested === null) {
|
||
|
// Warning if using content ID without setting the nested option
|
||
|
// Once the element is nested it is required to work properly in this case
|
||
|
console.warn('Remember to use the nested option if using the content ID option!');
|
||
|
}
|
||
|
|
||
|
if (this.nested === true) {
|
||
|
// Force transition overlap if nested
|
||
|
this.options.transition = 'overlap';
|
||
|
// Remove appropriate classes if already assigned in markup
|
||
|
this.$element.removeClass('is-transition-push');
|
||
|
}
|
||
|
|
||
|
this.$element.addClass(`is-transition-${this.options.transition} is-closed`);
|
||
|
|
||
|
// Find triggers that affect this element and add aria-expanded to them
|
||
|
this.$triggers = $(document)
|
||
|
.find('[data-open="'+id+'"], [data-close="'+id+'"], [data-toggle="'+id+'"]')
|
||
|
.attr('aria-expanded', 'false')
|
||
|
.attr('aria-controls', id);
|
||
|
|
||
|
// Get position by checking for related CSS class
|
||
|
this.position = this.$element.is('.position-left, .position-top, .position-right, .position-bottom') ? this.$element.attr('class').match(/position\-(left|top|right|bottom)/)[1] : this.position;
|
||
|
|
||
|
// Add an overlay over the content if necessary
|
||
|
if (this.options.contentOverlay === true) {
|
||
|
var overlay = document.createElement('div');
|
||
|
var overlayPosition = $(this.$element).css("position") === 'fixed' ? 'is-overlay-fixed' : 'is-overlay-absolute';
|
||
|
overlay.setAttribute('class', 'js-off-canvas-overlay ' + overlayPosition);
|
||
|
this.$overlay = $(overlay);
|
||
|
if(overlayPosition === 'is-overlay-fixed') {
|
||
|
$(this.$overlay).insertAfter(this.$element);
|
||
|
} else {
|
||
|
this.$content.append(this.$overlay);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// Get the revealOn option from the class.
|
||
|
var revealOnRegExp = new RegExp(RegExpEscape(this.options.revealClass) + '([^\\s]+)', 'g');
|
||
|
var revealOnClass = revealOnRegExp.exec(this.$element[0].className);
|
||
|
if (revealOnClass) {
|
||
|
this.options.isRevealed = true;
|
||
|
this.options.revealOn = this.options.revealOn || revealOnClass[1];
|
||
|
}
|
||
|
|
||
|
// Ensure the `reveal-on-*` class is set.
|
||
|
if (this.options.isRevealed === true && this.options.revealOn) {
|
||
|
this.$element.first().addClass(`${this.options.revealClass}${this.options.revealOn}`);
|
||
|
this._setMQChecker();
|
||
|
}
|
||
|
|
||
|
if (this.options.transitionTime) {
|
||
|
this.$element.css('transition-duration', this.options.transitionTime);
|
||
|
}
|
||
|
|
||
|
// Initally remove all transition/position CSS classes from off-canvas content container.
|
||
|
this._removeContentClasses();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds event handlers to the off-canvas wrapper and the exit overlay.
|
||
|
* @function
|
||
|
* @private
|
||
|
*/
|
||
|
_events() {
|
||
|
this.$element.off('.zf.trigger .zf.offcanvas').on({
|
||
|
'open.zf.trigger': this.open.bind(this),
|
||
|
'close.zf.trigger': this.close.bind(this),
|
||
|
'toggle.zf.trigger': this.toggle.bind(this),
|
||
|
'keydown.zf.offcanvas': this._handleKeyboard.bind(this)
|
||
|
});
|
||
|
|
||
|
if (this.options.closeOnClick === true) {
|
||
|
var $target = this.options.contentOverlay ? this.$overlay : this.$content;
|
||
|
$target.on({'click.zf.offcanvas': this.close.bind(this)});
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Applies event listener for elements that will reveal at certain breakpoints.
|
||
|
* @private
|
||
|
*/
|
||
|
_setMQChecker() {
|
||
|
var _this = this;
|
||
|
|
||
|
this.onLoadListener = onLoad($(window), function () {
|
||
|
if (MediaQuery.atLeast(_this.options.revealOn)) {
|
||
|
_this.reveal(true);
|
||
|
}
|
||
|
});
|
||
|
|
||
|
$(window).on('changed.zf.mediaquery', function () {
|
||
|
if (MediaQuery.atLeast(_this.options.revealOn)) {
|
||
|
_this.reveal(true);
|
||
|
} else {
|
||
|
_this.reveal(false);
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Removes the CSS transition/position classes of the off-canvas content container.
|
||
|
* Removing the classes is important when another off-canvas gets opened that uses the same content container.
|
||
|
* @param {Boolean} hasReveal - true if related off-canvas element is revealed.
|
||
|
* @private
|
||
|
*/
|
||
|
_removeContentClasses(hasReveal) {
|
||
|
if (typeof hasReveal !== 'boolean') {
|
||
|
this.$content.removeClass(this.contentClasses.base.join(' '));
|
||
|
} else if (hasReveal === false) {
|
||
|
this.$content.removeClass(`has-reveal-${this.position}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Adds the CSS transition/position classes of the off-canvas content container, based on the opening off-canvas element.
|
||
|
* Beforehand any transition/position class gets removed.
|
||
|
* @param {Boolean} hasReveal - true if related off-canvas element is revealed.
|
||
|
* @private
|
||
|
*/
|
||
|
_addContentClasses(hasReveal) {
|
||
|
this._removeContentClasses(hasReveal);
|
||
|
if (typeof hasReveal !== 'boolean') {
|
||
|
this.$content.addClass(`has-transition-${this.options.transition} has-position-${this.position}`);
|
||
|
} else if (hasReveal === true) {
|
||
|
this.$content.addClass(`has-reveal-${this.position}`);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handles the revealing/hiding the off-canvas at breakpoints, not the same as open.
|
||
|
* @param {Boolean} isRevealed - true if element should be revealed.
|
||
|
* @function
|
||
|
*/
|
||
|
reveal(isRevealed) {
|
||
|
if (isRevealed) {
|
||
|
this.close();
|
||
|
this.isRevealed = true;
|
||
|
this.$element.attr('aria-hidden', 'false');
|
||
|
this.$element.off('open.zf.trigger toggle.zf.trigger');
|
||
|
this.$element.removeClass('is-closed');
|
||
|
} else {
|
||
|
this.isRevealed = false;
|
||
|
this.$element.attr('aria-hidden', 'true');
|
||
|
this.$element.off('open.zf.trigger toggle.zf.trigger').on({
|
||
|
'open.zf.trigger': this.open.bind(this),
|
||
|
'toggle.zf.trigger': this.toggle.bind(this)
|
||
|
});
|
||
|
this.$element.addClass('is-closed');
|
||
|
}
|
||
|
this._addContentClasses(isRevealed);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Stops scrolling of the body when offcanvas is open on mobile Safari and other troublesome browsers.
|
||
|
* @private
|
||
|
*/
|
||
|
_stopScrolling(event) {
|
||
|
return false;
|
||
|
}
|
||
|
|
||
|
// Taken and adapted from http://stackoverflow.com/questions/16889447/prevent-full-page-scrolling-ios
|
||
|
// Only really works for y, not sure how to extend to x or if we need to.
|
||
|
_recordScrollable(event) {
|
||
|
let elem = this; // called from event handler context with this as elem
|
||
|
|
||
|
// If the element is scrollable (content overflows), then...
|
||
|
if (elem.scrollHeight !== elem.clientHeight) {
|
||
|
// If we're at the top, scroll down one pixel to allow scrolling up
|
||
|
if (elem.scrollTop === 0) {
|
||
|
elem.scrollTop = 1;
|
||
|
}
|
||
|
// If we're at the bottom, scroll up one pixel to allow scrolling down
|
||
|
if (elem.scrollTop === elem.scrollHeight - elem.clientHeight) {
|
||
|
elem.scrollTop = elem.scrollHeight - elem.clientHeight - 1;
|
||
|
}
|
||
|
}
|
||
|
elem.allowUp = elem.scrollTop > 0;
|
||
|
elem.allowDown = elem.scrollTop < (elem.scrollHeight - elem.clientHeight);
|
||
|
elem.lastY = event.originalEvent.pageY;
|
||
|
}
|
||
|
|
||
|
_stopScrollPropagation(event) {
|
||
|
let elem = this; // called from event handler context with this as elem
|
||
|
let up = event.pageY < elem.lastY;
|
||
|
let down = !up;
|
||
|
elem.lastY = event.pageY;
|
||
|
|
||
|
if((up && elem.allowUp) || (down && elem.allowDown)) {
|
||
|
event.stopPropagation();
|
||
|
} else {
|
||
|
event.preventDefault();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Opens the off-canvas menu.
|
||
|
* @function
|
||
|
* @param {Object} event - Event object passed from listener.
|
||
|
* @param {jQuery} trigger - element that triggered the off-canvas to open.
|
||
|
* @fires Offcanvas#opened
|
||
|
* @todo also trigger 'open' event?
|
||
|
*/
|
||
|
open(event, trigger) {
|
||
|
if (this.$element.hasClass('is-open') || this.isRevealed) { return; }
|
||
|
var _this = this;
|
||
|
|
||
|
if (trigger) {
|
||
|
this.$lastTrigger = trigger;
|
||
|
}
|
||
|
|
||
|
if (this.options.forceTo === 'top') {
|
||
|
window.scrollTo(0, 0);
|
||
|
} else if (this.options.forceTo === 'bottom') {
|
||
|
window.scrollTo(0,document.body.scrollHeight);
|
||
|
}
|
||
|
|
||
|
if (this.options.transitionTime && this.options.transition !== 'overlap') {
|
||
|
this.$element.siblings('[data-off-canvas-content]').css('transition-duration', this.options.transitionTime);
|
||
|
} else {
|
||
|
this.$element.siblings('[data-off-canvas-content]').css('transition-duration', '');
|
||
|
}
|
||
|
|
||
|
this.$element.addClass('is-open').removeClass('is-closed');
|
||
|
|
||
|
this.$triggers.attr('aria-expanded', 'true');
|
||
|
this.$element.attr('aria-hidden', 'false');
|
||
|
|
||
|
this.$content.addClass('is-open-' + this.position);
|
||
|
|
||
|
// If `contentScroll` is set to false, add class and disable scrolling on touch devices.
|
||
|
if (this.options.contentScroll === false) {
|
||
|
$('body').addClass('is-off-canvas-open').on('touchmove', this._stopScrolling);
|
||
|
this.$element.on('touchstart', this._recordScrollable);
|
||
|
this.$element.on('touchmove', this._stopScrollPropagation);
|
||
|
}
|
||
|
|
||
|
if (this.options.contentOverlay === true) {
|
||
|
this.$overlay.addClass('is-visible');
|
||
|
}
|
||
|
|
||
|
if (this.options.closeOnClick === true && this.options.contentOverlay === true) {
|
||
|
this.$overlay.addClass('is-closable');
|
||
|
}
|
||
|
|
||
|
if (this.options.autoFocus === true) {
|
||
|
this.$element.one(transitionend(this.$element), function() {
|
||
|
if (!_this.$element.hasClass('is-open')) {
|
||
|
return; // exit if prematurely closed
|
||
|
}
|
||
|
var canvasFocus = _this.$element.find('[data-autofocus]');
|
||
|
if (canvasFocus.length) {
|
||
|
canvasFocus.eq(0).focus();
|
||
|
} else {
|
||
|
_this.$element.find('a, button').eq(0).focus();
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
if (this.options.trapFocus === true) {
|
||
|
this.$content.attr('tabindex', '-1');
|
||
|
Keyboard.trapFocus(this.$element);
|
||
|
}
|
||
|
|
||
|
this._addContentClasses();
|
||
|
|
||
|
/**
|
||
|
* Fires when the off-canvas menu opens.
|
||
|
* @event Offcanvas#opened
|
||
|
*/
|
||
|
this.$element.trigger('opened.zf.offcanvas');
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Closes the off-canvas menu.
|
||
|
* @function
|
||
|
* @param {Function} cb - optional cb to fire after closure.
|
||
|
* @fires Offcanvas#closed
|
||
|
*/
|
||
|
close(cb) {
|
||
|
if (!this.$element.hasClass('is-open') || this.isRevealed) { return; }
|
||
|
|
||
|
var _this = this;
|
||
|
|
||
|
this.$element.removeClass('is-open');
|
||
|
|
||
|
this.$element.attr('aria-hidden', 'true')
|
||
|
/**
|
||
|
* Fires when the off-canvas menu opens.
|
||
|
* @event Offcanvas#closed
|
||
|
*/
|
||
|
.trigger('closed.zf.offcanvas');
|
||
|
|
||
|
this.$content.removeClass('is-open-left is-open-top is-open-right is-open-bottom');
|
||
|
|
||
|
// If `contentScroll` is set to false, remove class and re-enable scrolling on touch devices.
|
||
|
if (this.options.contentScroll === false) {
|
||
|
$('body').removeClass('is-off-canvas-open').off('touchmove', this._stopScrolling);
|
||
|
this.$element.off('touchstart', this._recordScrollable);
|
||
|
this.$element.off('touchmove', this._stopScrollPropagation);
|
||
|
}
|
||
|
|
||
|
if (this.options.contentOverlay === true) {
|
||
|
this.$overlay.removeClass('is-visible');
|
||
|
}
|
||
|
|
||
|
if (this.options.closeOnClick === true && this.options.contentOverlay === true) {
|
||
|
this.$overlay.removeClass('is-closable');
|
||
|
}
|
||
|
|
||
|
this.$triggers.attr('aria-expanded', 'false');
|
||
|
|
||
|
if (this.options.trapFocus === true) {
|
||
|
this.$content.removeAttr('tabindex');
|
||
|
Keyboard.releaseFocus(this.$element);
|
||
|
}
|
||
|
|
||
|
// Listen to transitionEnd and add class when done.
|
||
|
this.$element.one(transitionend(this.$element), function(e) {
|
||
|
_this.$element.addClass('is-closed');
|
||
|
_this._removeContentClasses();
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Toggles the off-canvas menu open or closed.
|
||
|
* @function
|
||
|
* @param {Object} event - Event object passed from listener.
|
||
|
* @param {jQuery} trigger - element that triggered the off-canvas to open.
|
||
|
*/
|
||
|
toggle(event, trigger) {
|
||
|
if (this.$element.hasClass('is-open')) {
|
||
|
this.close(event, trigger);
|
||
|
}
|
||
|
else {
|
||
|
this.open(event, trigger);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Handles keyboard input when detected. When the escape key is pressed, the off-canvas menu closes, and focus is restored to the element that opened the menu.
|
||
|
* @function
|
||
|
* @private
|
||
|
*/
|
||
|
_handleKeyboard(e) {
|
||
|
Keyboard.handleKey(e, 'OffCanvas', {
|
||
|
close: () => {
|
||
|
this.close();
|
||
|
this.$lastTrigger.focus();
|
||
|
return true;
|
||
|
},
|
||
|
handled: () => {
|
||
|
e.stopPropagation();
|
||
|
e.preventDefault();
|
||
|
}
|
||
|
});
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Destroys the offcanvas plugin.
|
||
|
* @function
|
||
|
*/
|
||
|
_destroy() {
|
||
|
this.close();
|
||
|
this.$element.off('.zf.trigger .zf.offcanvas');
|
||
|
this.$overlay.off('.zf.offcanvas');
|
||
|
if (this.onLoadListener) $(window).off(this.onLoadListener);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
OffCanvas.defaults = {
|
||
|
/**
|
||
|
* Allow the user to click outside of the menu to close it.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default true
|
||
|
*/
|
||
|
closeOnClick: true,
|
||
|
|
||
|
/**
|
||
|
* Adds an overlay on top of `[data-off-canvas-content]`.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default true
|
||
|
*/
|
||
|
contentOverlay: true,
|
||
|
|
||
|
/**
|
||
|
* Target an off-canvas content container by ID that may be placed anywhere. If null the closest content container will be taken.
|
||
|
* @option
|
||
|
* @type {?string}
|
||
|
* @default null
|
||
|
*/
|
||
|
contentId: null,
|
||
|
|
||
|
/**
|
||
|
* Define the off-canvas element is nested in an off-canvas content. This is required when using the contentId option for a nested element.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default null
|
||
|
*/
|
||
|
nested: null,
|
||
|
|
||
|
/**
|
||
|
* Enable/disable scrolling of the main content when an off canvas panel is open.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default true
|
||
|
*/
|
||
|
contentScroll: true,
|
||
|
|
||
|
/**
|
||
|
* Amount of time in ms the open and close transition requires. If none selected, pulls from body style.
|
||
|
* @option
|
||
|
* @type {number}
|
||
|
* @default null
|
||
|
*/
|
||
|
transitionTime: null,
|
||
|
|
||
|
/**
|
||
|
* Type of transition for the offcanvas menu. Options are 'push', 'detached' or 'slide'.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default push
|
||
|
*/
|
||
|
transition: 'push',
|
||
|
|
||
|
/**
|
||
|
* Force the page to scroll to top or bottom on open.
|
||
|
* @option
|
||
|
* @type {?string}
|
||
|
* @default null
|
||
|
*/
|
||
|
forceTo: null,
|
||
|
|
||
|
/**
|
||
|
* Allow the offcanvas to remain open for certain breakpoints.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default false
|
||
|
*/
|
||
|
isRevealed: false,
|
||
|
|
||
|
/**
|
||
|
* Breakpoint at which to reveal. JS will use a RegExp to target standard classes, if changing classnames, pass your class with the `revealClass` option.
|
||
|
* @option
|
||
|
* @type {?string}
|
||
|
* @default null
|
||
|
*/
|
||
|
revealOn: null,
|
||
|
|
||
|
/**
|
||
|
* Force focus to the offcanvas on open. If true, will focus the opening trigger on close.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default true
|
||
|
*/
|
||
|
autoFocus: true,
|
||
|
|
||
|
/**
|
||
|
* Class used to force an offcanvas to remain open. Foundation defaults for this are `reveal-for-large` & `reveal-for-medium`.
|
||
|
* @option
|
||
|
* @type {string}
|
||
|
* @default reveal-for-
|
||
|
* @todo improve the regex testing for this.
|
||
|
*/
|
||
|
revealClass: 'reveal-for-',
|
||
|
|
||
|
/**
|
||
|
* Triggers optional focus trapping when opening an offcanvas. Sets tabindex of [data-off-canvas-content] to -1 for accessibility purposes.
|
||
|
* @option
|
||
|
* @type {boolean}
|
||
|
* @default false
|
||
|
*/
|
||
|
trapFocus: false
|
||
|
}
|
||
|
|
||
|
export {OffCanvas};
|