UNPKG

17.2 kBJavaScriptView Raw
1'use strict';
2
3import $ from 'jquery';
4import { Keyboard } from './foundation.util.keyboard';
5import { Nest } from './foundation.util.nest';
6import { GetYoDigits, transitionend } from './foundation.util.core';
7import { Box } from './foundation.util.box';
8import { Plugin } from './foundation.plugin';
9
10/**
11 * Drilldown module.
12 * @module foundation.drilldown
13 * @requires foundation.util.keyboard
14 * @requires foundation.util.nest
15 * @requires foundation.util.box
16 */
17
18class Drilldown extends Plugin {
19 /**
20 * Creates a new instance of a drilldown menu.
21 * @class
22 * @name Drilldown
23 * @param {jQuery} element - jQuery object to make into an accordion menu.
24 * @param {Object} options - Overrides to the default plugin settings.
25 */
26 _setup(element, options) {
27 this.$element = element;
28 this.options = $.extend({}, Drilldown.defaults, this.$element.data(), options);
29 this.className = 'Drilldown'; // ie9 back compat
30
31 Nest.Feather(this.$element, 'drilldown');
32
33 this._init();
34
35 Keyboard.register('Drilldown', {
36 'ENTER': 'open',
37 'SPACE': 'open',
38 'ARROW_RIGHT': 'next',
39 'ARROW_UP': 'up',
40 'ARROW_DOWN': 'down',
41 'ARROW_LEFT': 'previous',
42 'ESCAPE': 'close',
43 'TAB': 'down',
44 'SHIFT_TAB': 'up'
45 });
46 }
47
48 /**
49 * Initializes the drilldown by creating jQuery collections of elements
50 * @private
51 */
52 _init() {
53 if(this.options.autoApplyClass) {
54 this.$element.addClass('drilldown');
55 }
56
57 this.$element.attr({
58 'role': 'tree',
59 'aria-multiselectable': false
60 });
61 this.$submenuAnchors = this.$element.find('li.is-drilldown-submenu-parent').children('a');
62 this.$submenus = this.$submenuAnchors.parent('li').children('[data-submenu]').attr('role', 'group');
63 this.$menuItems = this.$element.find('li').not('.js-drilldown-back').attr('role', 'treeitem').find('a');
64 this.$element.attr('data-mutate', (this.$element.attr('data-drilldown') || GetYoDigits(6, 'drilldown')));
65
66 this._prepareMenu();
67 this._registerEvents();
68
69 this._keyboardEvents();
70 }
71
72 /**
73 * prepares drilldown menu by setting attributes to links and elements
74 * sets a min height to prevent content jumping
75 * wraps the element if not already wrapped
76 * @private
77 * @function
78 */
79 _prepareMenu() {
80 var _this = this;
81 // if(!this.options.holdOpen){
82 // this._menuLinkEvents();
83 // }
84 this.$submenuAnchors.each(function(){
85 var $link = $(this);
86 var $sub = $link.parent();
87 if(_this.options.parentLink){
88 $link.clone().prependTo($sub.children('[data-submenu]')).wrap('<li class="is-submenu-parent-item is-submenu-item is-drilldown-submenu-item" role="menu-item"></li>');
89 }
90 $link.data('savedHref', $link.attr('href')).removeAttr('href').attr('tabindex', 0);
91 $link.children('[data-submenu]')
92 .attr({
93 'aria-hidden': true,
94 'tabindex': 0,
95 'role': 'group'
96 });
97 _this._events($link);
98 });
99 this.$submenus.each(function(){
100 var $menu = $(this),
101 $back = $menu.find('.js-drilldown-back');
102 if(!$back.length){
103 switch (_this.options.backButtonPosition) {
104 case "bottom":
105 $menu.append(_this.options.backButton);
106 break;
107 case "top":
108 $menu.prepend(_this.options.backButton);
109 break;
110 default:
111 console.error("Unsupported backButtonPosition value '" + _this.options.backButtonPosition + "'");
112 }
113 }
114 _this._back($menu);
115 });
116
117 this.$submenus.addClass('invisible');
118 if(!this.options.autoHeight) {
119 this.$submenus.addClass('drilldown-submenu-cover-previous');
120 }
121
122 // create a wrapper on element if it doesn't exist.
123 if(!this.$element.parent().hasClass('is-drilldown')){
124 this.$wrapper = $(this.options.wrapper).addClass('is-drilldown');
125 if(this.options.animateHeight) this.$wrapper.addClass('animate-height');
126 this.$element.wrap(this.$wrapper);
127 }
128 // set wrapper
129 this.$wrapper = this.$element.parent();
130 this.$wrapper.css(this._getMaxDims());
131 }
132
133 _resize() {
134 this.$wrapper.css({'max-width': 'none', 'min-height': 'none'});
135 // _getMaxDims has side effects (boo) but calling it should update all other necessary heights & widths
136 this.$wrapper.css(this._getMaxDims());
137 }
138
139 /**
140 * Adds event handlers to elements in the menu.
141 * @function
142 * @private
143 * @param {jQuery} $elem - the current menu item to add handlers to.
144 */
145 _events($elem) {
146 var _this = this;
147
148 $elem.off('click.zf.drilldown')
149 .on('click.zf.drilldown', function(e){
150 if($(e.target).parentsUntil('ul', 'li').hasClass('is-drilldown-submenu-parent')){
151 e.stopImmediatePropagation();
152 e.preventDefault();
153 }
154
155 // if(e.target !== e.currentTarget.firstElementChild){
156 // return false;
157 // }
158 _this._show($elem.parent('li'));
159
160 if(_this.options.closeOnClick){
161 var $body = $('body');
162 $body.off('.zf.drilldown').on('click.zf.drilldown', function(e){
163 if (e.target === _this.$element[0] || $.contains(_this.$element[0], e.target)) { return; }
164 e.preventDefault();
165 _this._hideAll();
166 $body.off('.zf.drilldown');
167 });
168 }
169 });
170 }
171
172 /**
173 * Adds event handlers to the menu element.
174 * @function
175 * @private
176 */
177 _registerEvents() {
178 if(this.options.scrollTop){
179 this._bindHandler = this._scrollTop.bind(this);
180 this.$element.on('open.zf.drilldown hide.zf.drilldown closed.zf.drilldown',this._bindHandler);
181 }
182 this.$element.on('mutateme.zf.trigger', this._resize.bind(this));
183 }
184
185 /**
186 * Scroll to Top of Element or data-scroll-top-element
187 * @function
188 * @fires Drilldown#scrollme
189 */
190 _scrollTop() {
191 var _this = this;
192 var $scrollTopElement = _this.options.scrollTopElement!=''?$(_this.options.scrollTopElement):_this.$element,
193 scrollPos = parseInt($scrollTopElement.offset().top+_this.options.scrollTopOffset, 10);
194 $('html, body').stop(true).animate({ scrollTop: scrollPos }, _this.options.animationDuration, _this.options.animationEasing,function(){
195 /**
196 * Fires after the menu has scrolled
197 * @event Drilldown#scrollme
198 */
199 if(this===$('html')[0])_this.$element.trigger('scrollme.zf.drilldown');
200 });
201 }
202
203 /**
204 * Adds keydown event listener to `li`'s in the menu.
205 * @private
206 */
207 _keyboardEvents() {
208 var _this = this;
209
210 this.$menuItems.add(this.$element.find('.js-drilldown-back > a, .is-submenu-parent-item > a')).on('keydown.zf.drilldown', function(e){
211 var $element = $(this),
212 $elements = $element.parent('li').parent('ul').children('li').children('a'),
213 $prevElement,
214 $nextElement;
215
216 $elements.each(function(i) {
217 if ($(this).is($element)) {
218 $prevElement = $elements.eq(Math.max(0, i-1));
219 $nextElement = $elements.eq(Math.min(i+1, $elements.length-1));
220 return;
221 }
222 });
223
224 Keyboard.handleKey(e, 'Drilldown', {
225 next: function() {
226 if ($element.is(_this.$submenuAnchors)) {
227 _this._show($element.parent('li'));
228 $element.parent('li').one(transitionend($element), function(){
229 $element.parent('li').find('ul li a').filter(_this.$menuItems).first().focus();
230 });
231 return true;
232 }
233 },
234 previous: function() {
235 _this._hide($element.parent('li').parent('ul'));
236 $element.parent('li').parent('ul').one(transitionend($element), function(){
237 setTimeout(function() {
238 $element.parent('li').parent('ul').parent('li').children('a').first().focus();
239 }, 1);
240 });
241 return true;
242 },
243 up: function() {
244 $prevElement.focus();
245 // Don't tap focus on first element in root ul
246 return !$element.is(_this.$element.find('> li:first-child > a'));
247 },
248 down: function() {
249 $nextElement.focus();
250 // Don't tap focus on last element in root ul
251 return !$element.is(_this.$element.find('> li:last-child > a'));
252 },
253 close: function() {
254 // Don't close on element in root ul
255 if (!$element.is(_this.$element.find('> li > a'))) {
256 _this._hide($element.parent().parent());
257 $element.parent().parent().siblings('a').focus();
258 }
259 },
260 open: function() {
261 if (!$element.is(_this.$menuItems)) { // not menu item means back button
262 _this._hide($element.parent('li').parent('ul'));
263 $element.parent('li').parent('ul').one(transitionend($element), function(){
264 setTimeout(function() {
265 $element.parent('li').parent('ul').parent('li').children('a').first().focus();
266 }, 1);
267 });
268 return true;
269 } else if ($element.is(_this.$submenuAnchors)) {
270 _this._show($element.parent('li'));
271 $element.parent('li').one(transitionend($element), function(){
272 $element.parent('li').find('ul li a').filter(_this.$menuItems).first().focus();
273 });
274 return true;
275 }
276 },
277 handled: function(preventDefault) {
278 if (preventDefault) {
279 e.preventDefault();
280 }
281 e.stopImmediatePropagation();
282 }
283 });
284 }); // end keyboardAccess
285 }
286
287 /**
288 * Closes all open elements, and returns to root menu.
289 * @function
290 * @fires Drilldown#closed
291 */
292 _hideAll() {
293 var $elem = this.$element.find('.is-drilldown-submenu.is-active').addClass('is-closing');
294 if(this.options.autoHeight) this.$wrapper.css({height:$elem.parent().closest('ul').data('calcHeight')});
295 $elem.one(transitionend($elem), function(e){
296 $elem.removeClass('is-active is-closing');
297 });
298 /**
299 * Fires when the menu is fully closed.
300 * @event Drilldown#closed
301 */
302 this.$element.trigger('closed.zf.drilldown');
303 }
304
305 /**
306 * Adds event listener for each `back` button, and closes open menus.
307 * @function
308 * @fires Drilldown#back
309 * @param {jQuery} $elem - the current sub-menu to add `back` event.
310 */
311 _back($elem) {
312 var _this = this;
313 $elem.off('click.zf.drilldown');
314 $elem.children('.js-drilldown-back')
315 .on('click.zf.drilldown', function(e){
316 e.stopImmediatePropagation();
317 // console.log('mouseup on back');
318 _this._hide($elem);
319
320 // If there is a parent submenu, call show
321 let parentSubMenu = $elem.parent('li').parent('ul').parent('li');
322 if (parentSubMenu.length) {
323 _this._show(parentSubMenu);
324 }
325 });
326 }
327
328 /**
329 * Adds event listener to menu items w/o submenus to close open menus on click.
330 * @function
331 * @private
332 */
333 _menuLinkEvents() {
334 var _this = this;
335 this.$menuItems.not('.is-drilldown-submenu-parent')
336 .off('click.zf.drilldown')
337 .on('click.zf.drilldown', function(e){
338 // e.stopImmediatePropagation();
339 setTimeout(function(){
340 _this._hideAll();
341 }, 0);
342 });
343 }
344
345 /**
346 * Opens a submenu.
347 * @function
348 * @fires Drilldown#open
349 * @param {jQuery} $elem - the current element with a submenu to open, i.e. the `li` tag.
350 */
351 _show($elem) {
352 if(this.options.autoHeight) this.$wrapper.css({height:$elem.children('[data-submenu]').data('calcHeight')});
353 $elem.attr('aria-expanded', true);
354 $elem.children('[data-submenu]').addClass('is-active').removeClass('invisible').attr('aria-hidden', false);
355 /**
356 * Fires when the submenu has opened.
357 * @event Drilldown#open
358 */
359 this.$element.trigger('open.zf.drilldown', [$elem]);
360 };
361
362 /**
363 * Hides a submenu
364 * @function
365 * @fires Drilldown#hide
366 * @param {jQuery} $elem - the current sub-menu to hide, i.e. the `ul` tag.
367 */
368 _hide($elem) {
369 if(this.options.autoHeight) this.$wrapper.css({height:$elem.parent().closest('ul').data('calcHeight')});
370 var _this = this;
371 $elem.parent('li').attr('aria-expanded', false);
372 $elem.attr('aria-hidden', true).addClass('is-closing')
373 $elem.addClass('is-closing')
374 .one(transitionend($elem), function(){
375 $elem.removeClass('is-active is-closing');
376 $elem.blur().addClass('invisible');
377 });
378 /**
379 * Fires when the submenu has closed.
380 * @event Drilldown#hide
381 */
382 $elem.trigger('hide.zf.drilldown', [$elem]);
383 }
384
385 /**
386 * Iterates through the nested menus to calculate the min-height, and max-width for the menu.
387 * Prevents content jumping.
388 * @function
389 * @private
390 */
391 _getMaxDims() {
392 var maxHeight = 0, result = {}, _this = this;
393 this.$submenus.add(this.$element).each(function(){
394 var numOfElems = $(this).children('li').length;
395 var height = Box.GetDimensions(this).height;
396 maxHeight = height > maxHeight ? height : maxHeight;
397 if(_this.options.autoHeight) {
398 $(this).data('calcHeight',height);
399 if (!$(this).hasClass('is-drilldown-submenu')) result['height'] = height;
400 }
401 });
402
403 if(!this.options.autoHeight) result['min-height'] = `${maxHeight}px`;
404
405 result['max-width'] = `${this.$element[0].getBoundingClientRect().width}px`;
406
407 return result;
408 }
409
410 /**
411 * Destroys the Drilldown Menu
412 * @function
413 */
414 _destroy() {
415 if(this.options.scrollTop) this.$element.off('.zf.drilldown',this._bindHandler);
416 this._hideAll();
417 this.$element.off('mutateme.zf.trigger');
418 Nest.Burn(this.$element, 'drilldown');
419 this.$element.unwrap()
420 .find('.js-drilldown-back, .is-submenu-parent-item').remove()
421 .end().find('.is-active, .is-closing, .is-drilldown-submenu').removeClass('is-active is-closing is-drilldown-submenu')
422 .end().find('[data-submenu]').removeAttr('aria-hidden tabindex role');
423 this.$submenuAnchors.each(function() {
424 $(this).off('.zf.drilldown');
425 });
426
427 this.$submenus.removeClass('drilldown-submenu-cover-previous invisible');
428
429 this.$element.find('a').each(function(){
430 var $link = $(this);
431 $link.removeAttr('tabindex');
432 if($link.data('savedHref')){
433 $link.attr('href', $link.data('savedHref')).removeData('savedHref');
434 }else{ return; }
435 });
436 };
437}
438
439Drilldown.defaults = {
440 /**
441 * Drilldowns depend on styles in order to function properly; in the default build of Foundation these are
442 * on the `drilldown` class. This option auto-applies this class to the drilldown upon initialization.
443 * @option
444 * @type {boolian}
445 * @default true
446 */
447 autoApplyClass: true,
448 /**
449 * Markup used for JS generated back button. Prepended or appended (see backButtonPosition) to submenu lists and deleted on `destroy` method, 'js-drilldown-back' class required. Remove the backslash (`\`) if copy and pasting.
450 * @option
451 * @type {string}
452 * @default '<li class="js-drilldown-back"><a tabindex="0">Back</a></li>'
453 */
454 backButton: '<li class="js-drilldown-back"><a tabindex="0">Back</a></li>',
455 /**
456 * Position the back button either at the top or bottom of drilldown submenus. Can be `'left'` or `'bottom'`.
457 * @option
458 * @type {string}
459 * @default top
460 */
461 backButtonPosition: 'top',
462 /**
463 * Markup used to wrap drilldown menu. Use a class name for independent styling; the JS applied class: `is-drilldown` is required. Remove the backslash (`\`) if copy and pasting.
464 * @option
465 * @type {string}
466 * @default '<div></div>'
467 */
468 wrapper: '<div></div>',
469 /**
470 * Adds the parent link to the submenu.
471 * @option
472 * @type {boolean}
473 * @default false
474 */
475 parentLink: false,
476 /**
477 * Allow the menu to return to root list on body click.
478 * @option
479 * @type {boolean}
480 * @default false
481 */
482 closeOnClick: false,
483 /**
484 * Allow the menu to auto adjust height.
485 * @option
486 * @type {boolean}
487 * @default false
488 */
489 autoHeight: false,
490 /**
491 * Animate the auto adjust height.
492 * @option
493 * @type {boolean}
494 * @default false
495 */
496 animateHeight: false,
497 /**
498 * Scroll to the top of the menu after opening a submenu or navigating back using the menu back button
499 * @option
500 * @type {boolean}
501 * @default false
502 */
503 scrollTop: false,
504 /**
505 * String jquery selector (for example 'body') of element to take offset().top from, if empty string the drilldown menu offset().top is taken
506 * @option
507 * @type {string}
508 * @default ''
509 */
510 scrollTopElement: '',
511 /**
512 * ScrollTop offset
513 * @option
514 * @type {number}
515 * @default 0
516 */
517 scrollTopOffset: 0,
518 /**
519 * Scroll animation duration
520 * @option
521 * @type {number}
522 * @default 500
523 */
524 animationDuration: 500,
525 /**
526 * Scroll animation easing. Can be `'swing'` or `'linear'`.
527 * @option
528 * @type {string}
529 * @see {@link https://api.jquery.com/animate|JQuery animate}
530 * @default 'swing'
531 */
532 animationEasing: 'swing'
533 // holdOpen: false
534};
535
536export {Drilldown};