123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561 |
- '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};
|