UNPKG

17.1 kBJavaScriptView Raw
1'use strict';
2
3import $ from 'jquery';
4import { Keyboard } from './foundation.util.keyboard';
5import { MediaQuery } from './foundation.util.mediaQuery';
6import { transitionend } from './foundation.util.core';
7import { Plugin } from './foundation.plugin';
8
9import { Triggers } from './foundation.util.triggers';
10
11/**
12 * OffCanvas module.
13 * @module foundation.offcanvas
14 * @requires foundation.util.keyboard
15 * @requires foundation.util.mediaQuery
16 * @requires foundation.util.triggers
17 */
18
19class OffCanvas extends Plugin {
20 /**
21 * Creates a new instance of an off-canvas wrapper.
22 * @class
23 * @name OffCanvas
24 * @fires OffCanvas#init
25 * @param {Object} element - jQuery object to initialize.
26 * @param {Object} options - Overrides to the default plugin settings.
27 */
28 _setup(element, options) {
29 this.className = 'OffCanvas'; // ie9 back compat
30 this.$element = element;
31 this.options = $.extend({}, OffCanvas.defaults, this.$element.data(), options);
32 this.contentClasses = { base: [], reveal: [] };
33 this.$lastTrigger = $();
34 this.$triggers = $();
35 this.position = 'left';
36 this.$content = $();
37 this.nested = !!(this.options.nested);
38
39 // Defines the CSS transition/position classes of the off-canvas content container.
40 $(['push', 'overlap']).each((index, val) => {
41 this.contentClasses.base.push('has-transition-'+val);
42 });
43 $(['left', 'right', 'top', 'bottom']).each((index, val) => {
44 this.contentClasses.base.push('has-position-'+val);
45 this.contentClasses.reveal.push('has-reveal-'+val);
46 });
47
48 // Triggers init is idempotent, just need to make sure it is initialized
49 Triggers.init($);
50 MediaQuery._init();
51
52 this._init();
53 this._events();
54
55 Keyboard.register('OffCanvas', {
56 'ESCAPE': 'close'
57 });
58
59 }
60
61 /**
62 * Initializes the off-canvas wrapper by adding the exit overlay (if needed).
63 * @function
64 * @private
65 */
66 _init() {
67 var id = this.$element.attr('id');
68
69 this.$element.attr('aria-hidden', 'true');
70
71 // Find off-canvas content, either by ID (if specified), by siblings or by closest selector (fallback)
72 if (this.options.contentId) {
73 this.$content = $('#'+this.options.contentId);
74 } else if (this.$element.siblings('[data-off-canvas-content]').length) {
75 this.$content = this.$element.siblings('[data-off-canvas-content]').first();
76 } else {
77 this.$content = this.$element.closest('[data-off-canvas-content]').first();
78 }
79
80 if (!this.options.contentId) {
81 // Assume that the off-canvas element is nested if it isn't a sibling of the content
82 this.nested = this.$element.siblings('[data-off-canvas-content]').length === 0;
83
84 } else if (this.options.contentId && this.options.nested === null) {
85 // Warning if using content ID without setting the nested option
86 // Once the element is nested it is required to work properly in this case
87 console.warn('Remember to use the nested option if using the content ID option!');
88 }
89
90 if (this.nested === true) {
91 // Force transition overlap if nested
92 this.options.transition = 'overlap';
93 // Remove appropriate classes if already assigned in markup
94 this.$element.removeClass('is-transition-push');
95 }
96
97 this.$element.addClass(`is-transition-${this.options.transition} is-closed`);
98
99 // Find triggers that affect this element and add aria-expanded to them
100 this.$triggers = $(document)
101 .find('[data-open="'+id+'"], [data-close="'+id+'"], [data-toggle="'+id+'"]')
102 .attr('aria-expanded', 'false')
103 .attr('aria-controls', id);
104
105 // Get position by checking for related CSS class
106 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;
107
108 // Add an overlay over the content if necessary
109 if (this.options.contentOverlay === true) {
110 var overlay = document.createElement('div');
111 var overlayPosition = $(this.$element).css("position") === 'fixed' ? 'is-overlay-fixed' : 'is-overlay-absolute';
112 overlay.setAttribute('class', 'js-off-canvas-overlay ' + overlayPosition);
113 this.$overlay = $(overlay);
114 if(overlayPosition === 'is-overlay-fixed') {
115 $(this.$overlay).insertAfter(this.$element);
116 } else {
117 this.$content.append(this.$overlay);
118 }
119 }
120
121 this.options.isRevealed = this.options.isRevealed || new RegExp(this.options.revealClass, 'g').test(this.$element[0].className);
122
123 if (this.options.isRevealed === true) {
124 this.options.revealOn = this.options.revealOn || this.$element[0].className.match(/(reveal-for-medium|reveal-for-large)/g)[0].split('-')[2];
125 this._setMQChecker();
126 }
127
128 if (this.options.transitionTime) {
129 this.$element.css('transition-duration', this.options.transitionTime);
130 }
131
132 // Initally remove all transition/position CSS classes from off-canvas content container.
133 this._removeContentClasses();
134 }
135
136 /**
137 * Adds event handlers to the off-canvas wrapper and the exit overlay.
138 * @function
139 * @private
140 */
141 _events() {
142 this.$element.off('.zf.trigger .zf.offcanvas').on({
143 'open.zf.trigger': this.open.bind(this),
144 'close.zf.trigger': this.close.bind(this),
145 'toggle.zf.trigger': this.toggle.bind(this),
146 'keydown.zf.offcanvas': this._handleKeyboard.bind(this)
147 });
148
149 if (this.options.closeOnClick === true) {
150 var $target = this.options.contentOverlay ? this.$overlay : this.$content;
151 $target.on({'click.zf.offcanvas': this.close.bind(this)});
152 }
153 }
154
155 /**
156 * Applies event listener for elements that will reveal at certain breakpoints.
157 * @private
158 */
159 _setMQChecker() {
160 var _this = this;
161
162 $(window).on('changed.zf.mediaquery', function() {
163 if (MediaQuery.atLeast(_this.options.revealOn)) {
164 _this.reveal(true);
165 } else {
166 _this.reveal(false);
167 }
168 }).one('load.zf.offcanvas', function() {
169 if (MediaQuery.atLeast(_this.options.revealOn)) {
170 _this.reveal(true);
171 }
172 });
173 }
174
175 /**
176 * Removes the CSS transition/position classes of the off-canvas content container.
177 * Removing the classes is important when another off-canvas gets opened that uses the same content container.
178 * @private
179 */
180 _removeContentClasses(hasReveal) {
181 this.$content.removeClass(this.contentClasses.base.join(' '));
182 if (hasReveal === true) {
183 this.$content.removeClass(this.contentClasses.reveal.join(' '));
184 }
185 }
186
187 /**
188 * Adds the CSS transition/position classes of the off-canvas content container, based on the opening off-canvas element.
189 * Beforehand any transition/position class gets removed.
190 * @param {Boolean} hasReveal - true if related off-canvas element is revealed.
191 * @private
192 */
193 _addContentClasses(hasReveal) {
194 this._removeContentClasses();
195 this.$content.addClass(`has-transition-${this.options.transition} has-position-${this.position}`);
196 if (hasReveal === true) {
197 this.$content.addClass(`has-reveal-${this.position}`);
198 }
199 }
200
201 /**
202 * Handles the revealing/hiding the off-canvas at breakpoints, not the same as open.
203 * @param {Boolean} isRevealed - true if element should be revealed.
204 * @function
205 */
206 reveal(isRevealed) {
207 if (isRevealed) {
208 this.close();
209 this.isRevealed = true;
210 this.$element.attr('aria-hidden', 'false');
211 this.$element.off('open.zf.trigger toggle.zf.trigger');
212 this.$element.removeClass('is-closed');
213 } else {
214 this.isRevealed = false;
215 this.$element.attr('aria-hidden', 'true');
216 this.$element.off('open.zf.trigger toggle.zf.trigger').on({
217 'open.zf.trigger': this.open.bind(this),
218 'toggle.zf.trigger': this.toggle.bind(this)
219 });
220 this.$element.addClass('is-closed');
221 }
222 this._addContentClasses(isRevealed);
223 }
224
225 /**
226 * Stops scrolling of the body when offcanvas is open on mobile Safari and other troublesome browsers.
227 * @private
228 */
229 _stopScrolling(event) {
230 return false;
231 }
232
233 // Taken and adapted from http://stackoverflow.com/questions/16889447/prevent-full-page-scrolling-ios
234 // Only really works for y, not sure how to extend to x or if we need to.
235 _recordScrollable(event) {
236 let elem = this; // called from event handler context with this as elem
237
238 // If the element is scrollable (content overflows), then...
239 if (elem.scrollHeight !== elem.clientHeight) {
240 // If we're at the top, scroll down one pixel to allow scrolling up
241 if (elem.scrollTop === 0) {
242 elem.scrollTop = 1;
243 }
244 // If we're at the bottom, scroll up one pixel to allow scrolling down
245 if (elem.scrollTop === elem.scrollHeight - elem.clientHeight) {
246 elem.scrollTop = elem.scrollHeight - elem.clientHeight - 1;
247 }
248 }
249 elem.allowUp = elem.scrollTop > 0;
250 elem.allowDown = elem.scrollTop < (elem.scrollHeight - elem.clientHeight);
251 elem.lastY = event.originalEvent.pageY;
252 }
253
254 _stopScrollPropagation(event) {
255 let elem = this; // called from event handler context with this as elem
256 let up = event.pageY < elem.lastY;
257 let down = !up;
258 elem.lastY = event.pageY;
259
260 if((up && elem.allowUp) || (down && elem.allowDown)) {
261 event.stopPropagation();
262 } else {
263 event.preventDefault();
264 }
265 }
266
267 /**
268 * Opens the off-canvas menu.
269 * @function
270 * @param {Object} event - Event object passed from listener.
271 * @param {jQuery} trigger - element that triggered the off-canvas to open.
272 * @fires OffCanvas#opened
273 */
274 open(event, trigger) {
275 if (this.$element.hasClass('is-open') || this.isRevealed) { return; }
276 var _this = this;
277
278 if (trigger) {
279 this.$lastTrigger = trigger;
280 }
281
282 if (this.options.forceTo === 'top') {
283 window.scrollTo(0, 0);
284 } else if (this.options.forceTo === 'bottom') {
285 window.scrollTo(0,document.body.scrollHeight);
286 }
287
288 if (this.options.transitionTime && this.options.transition !== 'overlap') {
289 this.$element.siblings('[data-off-canvas-content]').css('transition-duration', this.options.transitionTime);
290 } else {
291 this.$element.siblings('[data-off-canvas-content]').css('transition-duration', '');
292 }
293
294 /**
295 * Fires when the off-canvas menu opens.
296 * @event OffCanvas#opened
297 */
298 this.$element.addClass('is-open').removeClass('is-closed');
299
300 this.$triggers.attr('aria-expanded', 'true');
301 this.$element.attr('aria-hidden', 'false')
302 .trigger('opened.zf.offcanvas');
303
304 this.$content.addClass('is-open-' + this.position);
305
306 // If `contentScroll` is set to false, add class and disable scrolling on touch devices.
307 if (this.options.contentScroll === false) {
308 $('body').addClass('is-off-canvas-open').on('touchmove', this._stopScrolling);
309 this.$element.on('touchstart', this._recordScrollable);
310 this.$element.on('touchmove', this._stopScrollPropagation);
311 }
312
313 if (this.options.contentOverlay === true) {
314 this.$overlay.addClass('is-visible');
315 }
316
317 if (this.options.closeOnClick === true && this.options.contentOverlay === true) {
318 this.$overlay.addClass('is-closable');
319 }
320
321 if (this.options.autoFocus === true) {
322 this.$element.one(transitionend(this.$element), function() {
323 if (!_this.$element.hasClass('is-open')) {
324 return; // exit if prematurely closed
325 }
326 var canvasFocus = _this.$element.find('[data-autofocus]');
327 if (canvasFocus.length) {
328 canvasFocus.eq(0).focus();
329 } else {
330 _this.$element.find('a, button').eq(0).focus();
331 }
332 });
333 }
334
335 if (this.options.trapFocus === true) {
336 this.$content.attr('tabindex', '-1');
337 Keyboard.trapFocus(this.$element);
338 }
339
340 this._addContentClasses();
341 }
342
343 /**
344 * Closes the off-canvas menu.
345 * @function
346 * @param {Function} cb - optional cb to fire after closure.
347 * @fires OffCanvas#closed
348 */
349 close(cb) {
350 if (!this.$element.hasClass('is-open') || this.isRevealed) { return; }
351
352 var _this = this;
353
354 this.$element.removeClass('is-open');
355
356 this.$element.attr('aria-hidden', 'true')
357 /**
358 * Fires when the off-canvas menu opens.
359 * @event OffCanvas#closed
360 */
361 .trigger('closed.zf.offcanvas');
362
363 this.$content.removeClass('is-open-left is-open-top is-open-right is-open-bottom');
364
365 // If `contentScroll` is set to false, remove class and re-enable scrolling on touch devices.
366 if (this.options.contentScroll === false) {
367 $('body').removeClass('is-off-canvas-open').off('touchmove', this._stopScrolling);
368 this.$element.off('touchstart', this._recordScrollable);
369 this.$element.off('touchmove', this._stopScrollPropagation);
370 }
371
372 if (this.options.contentOverlay === true) {
373 this.$overlay.removeClass('is-visible');
374 }
375
376 if (this.options.closeOnClick === true && this.options.contentOverlay === true) {
377 this.$overlay.removeClass('is-closable');
378 }
379
380 this.$triggers.attr('aria-expanded', 'false');
381
382 if (this.options.trapFocus === true) {
383 this.$content.removeAttr('tabindex');
384 Keyboard.releaseFocus(this.$element);
385 }
386
387 // Listen to transitionEnd and add class when done.
388 this.$element.one(transitionend(this.$element), function(e) {
389 _this.$element.addClass('is-closed');
390 _this._removeContentClasses();
391 });
392 }
393
394 /**
395 * Toggles the off-canvas menu open or closed.
396 * @function
397 * @param {Object} event - Event object passed from listener.
398 * @param {jQuery} trigger - element that triggered the off-canvas to open.
399 */
400 toggle(event, trigger) {
401 if (this.$element.hasClass('is-open')) {
402 this.close(event, trigger);
403 }
404 else {
405 this.open(event, trigger);
406 }
407 }
408
409 /**
410 * 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.
411 * @function
412 * @private
413 */
414 _handleKeyboard(e) {
415 Keyboard.handleKey(e, 'OffCanvas', {
416 close: () => {
417 this.close();
418 this.$lastTrigger.focus();
419 return true;
420 },
421 handled: () => {
422 e.stopPropagation();
423 e.preventDefault();
424 }
425 });
426 }
427
428 /**
429 * Destroys the offcanvas plugin.
430 * @function
431 */
432 _destroy() {
433 this.close();
434 this.$element.off('.zf.trigger .zf.offcanvas');
435 this.$overlay.off('.zf.offcanvas');
436 }
437}
438
439OffCanvas.defaults = {
440 /**
441 * Allow the user to click outside of the menu to close it.
442 * @option
443 * @type {boolean}
444 * @default true
445 */
446 closeOnClick: true,
447
448 /**
449 * Adds an overlay on top of `[data-off-canvas-content]`.
450 * @option
451 * @type {boolean}
452 * @default true
453 */
454 contentOverlay: true,
455
456 /**
457 * Target an off-canvas content container by ID that may be placed anywhere. If null the closest content container will be taken.
458 * @option
459 * @type {?string}
460 * @default null
461 */
462 contentId: null,
463
464 /**
465 * Define the off-canvas element is nested in an off-canvas content. This is required when using the contentId option for a nested element.
466 * @option
467 * @type {boolean}
468 * @default null
469 */
470 nested: null,
471
472 /**
473 * Enable/disable scrolling of the main content when an off canvas panel is open.
474 * @option
475 * @type {boolean}
476 * @default true
477 */
478 contentScroll: true,
479
480 /**
481 * Amount of time in ms the open and close transition requires. If none selected, pulls from body style.
482 * @option
483 * @type {number}
484 * @default null
485 */
486 transitionTime: null,
487
488 /**
489 * Type of transition for the offcanvas menu. Options are 'push', 'detached' or 'slide'.
490 * @option
491 * @type {string}
492 * @default push
493 */
494 transition: 'push',
495
496 /**
497 * Force the page to scroll to top or bottom on open.
498 * @option
499 * @type {?string}
500 * @default null
501 */
502 forceTo: null,
503
504 /**
505 * Allow the offcanvas to remain open for certain breakpoints.
506 * @option
507 * @type {boolean}
508 * @default false
509 */
510 isRevealed: false,
511
512 /**
513 * Breakpoint at which to reveal. JS will use a RegExp to target standard classes, if changing classnames, pass your class with the `revealClass` option.
514 * @option
515 * @type {?string}
516 * @default null
517 */
518 revealOn: null,
519
520 /**
521 * Force focus to the offcanvas on open. If true, will focus the opening trigger on close.
522 * @option
523 * @type {boolean}
524 * @default true
525 */
526 autoFocus: true,
527
528 /**
529 * Class used to force an offcanvas to remain open. Foundation defaults for this are `reveal-for-large` & `reveal-for-medium`.
530 * @option
531 * @type {string}
532 * @default reveal-for-
533 * @todo improve the regex testing for this.
534 */
535 revealClass: 'reveal-for-',
536
537 /**
538 * Triggers optional focus trapping when opening an offcanvas. Sets tabindex of [data-off-canvas-content] to -1 for accessibility purposes.
539 * @option
540 * @type {boolean}
541 * @default false
542 */
543 trapFocus: false
544}
545
546export {OffCanvas};