UNPKG

14 kBJavaScriptView Raw
1'use strict';
2
3import $ from 'jquery';
4import { Keyboard } from './foundation.util.keyboard';
5import { Nest } from './foundation.util.nest';
6import { Box } from './foundation.util.box';
7import { rtl as Rtl } from './foundation.util.core';
8import { Plugin } from './foundation.plugin';
9
10
11/**
12 * DropdownMenu module.
13 * @module foundation.dropdown-menu
14 * @requires foundation.util.keyboard
15 * @requires foundation.util.box
16 * @requires foundation.util.nest
17 */
18
19class DropdownMenu extends Plugin {
20 /**
21 * Creates a new instance of DropdownMenu.
22 * @class
23 * @name DropdownMenu
24 * @fires DropdownMenu#init
25 * @param {jQuery} element - jQuery object to make into a dropdown menu.
26 * @param {Object} options - Overrides to the default plugin settings.
27 */
28 _setup(element, options) {
29 this.$element = element;
30 this.options = $.extend({}, DropdownMenu.defaults, this.$element.data(), options);
31 this.className = 'DropdownMenu'; // ie9 back compat
32
33 this._init();
34
35 Keyboard.register('DropdownMenu', {
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 });
44 }
45
46 /**
47 * Initializes the plugin, and calls _prepareMenu
48 * @private
49 * @function
50 */
51 _init() {
52 Nest.Feather(this.$element, 'dropdown');
53
54 var subs = this.$element.find('li.is-dropdown-submenu-parent');
55 this.$element.children('.is-dropdown-submenu-parent').children('.is-dropdown-submenu').addClass('first-sub');
56
57 this.$menuItems = this.$element.find('[role="menuitem"]');
58 this.$tabs = this.$element.children('[role="menuitem"]');
59 this.$tabs.find('ul.is-dropdown-submenu').addClass(this.options.verticalClass);
60
61 if (this.options.alignment === 'auto') {
62 if (this.$element.hasClass(this.options.rightClass) || Rtl() || this.$element.parents('.top-bar-right').is('*')) {
63 this.options.alignment = 'right';
64 subs.addClass('opens-left');
65 } else {
66 this.options.alignment = 'left';
67 subs.addClass('opens-right');
68 }
69 } else {
70 if (this.options.alignment === 'right') {
71 subs.addClass('opens-left');
72 } else {
73 subs.addClass('opens-right');
74 }
75 }
76 this.changed = false;
77 this._events();
78 };
79
80 _isVertical() {
81 return this.$tabs.css('display') === 'block' || this.$element.css('flex-direction') === 'column';
82 }
83
84 _isRtl() {
85 return this.$element.hasClass('align-right') || (Rtl() && !this.$element.hasClass('align-left'));
86 }
87
88 /**
89 * Adds event listeners to elements within the menu
90 * @private
91 * @function
92 */
93 _events() {
94 var _this = this,
95 hasTouch = 'ontouchstart' in window || (typeof window.ontouchstart !== 'undefined'),
96 parClass = 'is-dropdown-submenu-parent';
97
98 // used for onClick and in the keyboard handlers
99 var handleClickFn = function(e) {
100 var $elem = $(e.target).parentsUntil('ul', `.${parClass}`),
101 hasSub = $elem.hasClass(parClass),
102 hasClicked = $elem.attr('data-is-click') === 'true',
103 $sub = $elem.children('.is-dropdown-submenu');
104
105 if (hasSub) {
106 if (hasClicked) {
107 if (!_this.options.closeOnClick || (!_this.options.clickOpen && !hasTouch) || (_this.options.forceFollow && hasTouch)) { return; }
108 else {
109 e.stopImmediatePropagation();
110 e.preventDefault();
111 _this._hide($elem);
112 }
113 } else {
114 e.preventDefault();
115 e.stopImmediatePropagation();
116 _this._show($sub);
117 $elem.add($elem.parentsUntil(_this.$element, `.${parClass}`)).attr('data-is-click', true);
118 }
119 }
120 };
121
122 if (this.options.clickOpen || hasTouch) {
123 this.$menuItems.on('click.zf.dropdownmenu touchstart.zf.dropdownmenu', handleClickFn);
124 }
125
126 // Handle Leaf element Clicks
127 if(_this.options.closeOnClickInside){
128 this.$menuItems.on('click.zf.dropdownmenu', function(e) {
129 var $elem = $(this),
130 hasSub = $elem.hasClass(parClass);
131 if(!hasSub){
132 _this._hide();
133 }
134 });
135 }
136
137 if (!this.options.disableHover) {
138 this.$menuItems.on('mouseenter.zf.dropdownmenu', function(e) {
139 var $elem = $(this),
140 hasSub = $elem.hasClass(parClass);
141
142 if (hasSub) {
143 clearTimeout($elem.data('_delay'));
144 $elem.data('_delay', setTimeout(function() {
145 _this._show($elem.children('.is-dropdown-submenu'));
146 }, _this.options.hoverDelay));
147 }
148 }).on('mouseleave.zf.dropdownmenu', function(e) {
149 var $elem = $(this),
150 hasSub = $elem.hasClass(parClass);
151 if (hasSub && _this.options.autoclose) {
152 if ($elem.attr('data-is-click') === 'true' && _this.options.clickOpen) { return false; }
153
154 clearTimeout($elem.data('_delay'));
155 $elem.data('_delay', setTimeout(function() {
156 _this._hide($elem);
157 }, _this.options.closingTime));
158 }
159 });
160 }
161 this.$menuItems.on('keydown.zf.dropdownmenu', function(e) {
162 var $element = $(e.target).parentsUntil('ul', '[role="menuitem"]'),
163 isTab = _this.$tabs.index($element) > -1,
164 $elements = isTab ? _this.$tabs : $element.siblings('li').add($element),
165 $prevElement,
166 $nextElement;
167
168 $elements.each(function(i) {
169 if ($(this).is($element)) {
170 $prevElement = $elements.eq(i-1);
171 $nextElement = $elements.eq(i+1);
172 return;
173 }
174 });
175
176 var nextSibling = function() {
177 $nextElement.children('a:first').focus();
178 e.preventDefault();
179 }, prevSibling = function() {
180 $prevElement.children('a:first').focus();
181 e.preventDefault();
182 }, openSub = function() {
183 var $sub = $element.children('ul.is-dropdown-submenu');
184 if ($sub.length) {
185 _this._show($sub);
186 $element.find('li > a:first').focus();
187 e.preventDefault();
188 } else { return; }
189 }, closeSub = function() {
190 //if ($element.is(':first-child')) {
191 var close = $element.parent('ul').parent('li');
192 close.children('a:first').focus();
193 _this._hide(close);
194 e.preventDefault();
195 //}
196 };
197 var functions = {
198 open: openSub,
199 close: function() {
200 _this._hide(_this.$element);
201 _this.$menuItems.eq(0).children('a').focus(); // focus to first element
202 e.preventDefault();
203 },
204 handled: function() {
205 e.stopImmediatePropagation();
206 }
207 };
208
209 if (isTab) {
210 if (_this._isVertical()) { // vertical menu
211 if (_this._isRtl()) { // right aligned
212 $.extend(functions, {
213 down: nextSibling,
214 up: prevSibling,
215 next: closeSub,
216 previous: openSub
217 });
218 } else { // left aligned
219 $.extend(functions, {
220 down: nextSibling,
221 up: prevSibling,
222 next: openSub,
223 previous: closeSub
224 });
225 }
226 } else { // horizontal menu
227 if (_this._isRtl()) { // right aligned
228 $.extend(functions, {
229 next: prevSibling,
230 previous: nextSibling,
231 down: openSub,
232 up: closeSub
233 });
234 } else { // left aligned
235 $.extend(functions, {
236 next: nextSibling,
237 previous: prevSibling,
238 down: openSub,
239 up: closeSub
240 });
241 }
242 }
243 } else { // not tabs -> one sub
244 if (_this._isRtl()) { // right aligned
245 $.extend(functions, {
246 next: closeSub,
247 previous: openSub,
248 down: nextSibling,
249 up: prevSibling
250 });
251 } else { // left aligned
252 $.extend(functions, {
253 next: openSub,
254 previous: closeSub,
255 down: nextSibling,
256 up: prevSibling
257 });
258 }
259 }
260 Keyboard.handleKey(e, 'DropdownMenu', functions);
261
262 });
263 }
264
265 /**
266 * Adds an event handler to the body to close any dropdowns on a click.
267 * @function
268 * @private
269 */
270 _addBodyHandler() {
271 var $body = $(document.body),
272 _this = this;
273 $body.off('mouseup.zf.dropdownmenu touchend.zf.dropdownmenu')
274 .on('mouseup.zf.dropdownmenu touchend.zf.dropdownmenu', function(e) {
275 var $link = _this.$element.find(e.target);
276 if ($link.length) { return; }
277
278 _this._hide();
279 $body.off('mouseup.zf.dropdownmenu touchend.zf.dropdownmenu');
280 });
281 }
282
283 /**
284 * Opens a dropdown pane, and checks for collisions first.
285 * @param {jQuery} $sub - ul element that is a submenu to show
286 * @function
287 * @private
288 * @fires DropdownMenu#show
289 */
290 _show($sub) {
291 var idx = this.$tabs.index(this.$tabs.filter(function(i, el) {
292 return $(el).find($sub).length > 0;
293 }));
294 var $sibs = $sub.parent('li.is-dropdown-submenu-parent').siblings('li.is-dropdown-submenu-parent');
295 this._hide($sibs, idx);
296 $sub.css('visibility', 'hidden').addClass('js-dropdown-active')
297 .parent('li.is-dropdown-submenu-parent').addClass('is-active');
298 var clear = Box.ImNotTouchingYou($sub, null, true);
299 if (!clear) {
300 var oldClass = this.options.alignment === 'left' ? '-right' : '-left',
301 $parentLi = $sub.parent('.is-dropdown-submenu-parent');
302 $parentLi.removeClass(`opens${oldClass}`).addClass(`opens-${this.options.alignment}`);
303 clear = Box.ImNotTouchingYou($sub, null, true);
304 if (!clear) {
305 $parentLi.removeClass(`opens-${this.options.alignment}`).addClass('opens-inner');
306 }
307 this.changed = true;
308 }
309 $sub.css('visibility', '');
310 if (this.options.closeOnClick) { this._addBodyHandler(); }
311 /**
312 * Fires when the new dropdown pane is visible.
313 * @event DropdownMenu#show
314 */
315 this.$element.trigger('show.zf.dropdownmenu', [$sub]);
316 }
317
318 /**
319 * Hides a single, currently open dropdown pane, if passed a parameter, otherwise, hides everything.
320 * @function
321 * @param {jQuery} $elem - element with a submenu to hide
322 * @param {Number} idx - index of the $tabs collection to hide
323 * @private
324 */
325 _hide($elem, idx) {
326 var $toClose;
327 if ($elem && $elem.length) {
328 $toClose = $elem;
329 } else if (idx !== undefined) {
330 $toClose = this.$tabs.not(function(i, el) {
331 return i === idx;
332 });
333 }
334 else {
335 $toClose = this.$element;
336 }
337 var somethingToClose = $toClose.hasClass('is-active') || $toClose.find('.is-active').length > 0;
338
339 if (somethingToClose) {
340 $toClose.find('li.is-active').add($toClose).attr({
341 'data-is-click': false
342 }).removeClass('is-active');
343
344 $toClose.find('ul.js-dropdown-active').removeClass('js-dropdown-active');
345
346 if (this.changed || $toClose.find('opens-inner').length) {
347 var oldClass = this.options.alignment === 'left' ? 'right' : 'left';
348 $toClose.find('li.is-dropdown-submenu-parent').add($toClose)
349 .removeClass(`opens-inner opens-${this.options.alignment}`)
350 .addClass(`opens-${oldClass}`);
351 this.changed = false;
352 }
353 /**
354 * Fires when the open menus are closed.
355 * @event DropdownMenu#hide
356 */
357 this.$element.trigger('hide.zf.dropdownmenu', [$toClose]);
358 }
359 }
360
361 /**
362 * Destroys the plugin.
363 * @function
364 */
365 _destroy() {
366 this.$menuItems.off('.zf.dropdownmenu').removeAttr('data-is-click')
367 .removeClass('is-right-arrow is-left-arrow is-down-arrow opens-right opens-left opens-inner');
368 $(document.body).off('.zf.dropdownmenu');
369 Nest.Burn(this.$element, 'dropdown');
370 }
371}
372
373/**
374 * Default settings for plugin
375 */
376DropdownMenu.defaults = {
377 /**
378 * Disallows hover events from opening submenus
379 * @option
380 * @type {boolean}
381 * @default false
382 */
383 disableHover: false,
384 /**
385 * Allow a submenu to automatically close on a mouseleave event, if not clicked open.
386 * @option
387 * @type {boolean}
388 * @default true
389 */
390 autoclose: true,
391 /**
392 * Amount of time to delay opening a submenu on hover event.
393 * @option
394 * @type {number}
395 * @default 50
396 */
397 hoverDelay: 50,
398 /**
399 * Allow a submenu to open/remain open on parent click event. Allows cursor to move away from menu.
400 * @option
401 * @type {boolean}
402 * @default false
403 */
404 clickOpen: false,
405 /**
406 * Amount of time to delay closing a submenu on a mouseleave event.
407 * @option
408 * @type {number}
409 * @default 500
410 */
411
412 closingTime: 500,
413 /**
414 * Position of the menu relative to what direction the submenus should open. Handled by JS. Can be `'auto'`, `'left'` or `'right'`.
415 * @option
416 * @type {string}
417 * @default 'auto'
418 */
419 alignment: 'auto',
420 /**
421 * Allow clicks on the body to close any open submenus.
422 * @option
423 * @type {boolean}
424 * @default true
425 */
426 closeOnClick: true,
427 /**
428 * Allow clicks on leaf anchor links to close any open submenus.
429 * @option
430 * @type {boolean}
431 * @default true
432 */
433 closeOnClickInside: true,
434 /**
435 * Class applied to vertical oriented menus, Foundation default is `vertical`. Update this if using your own class.
436 * @option
437 * @type {string}
438 * @default 'vertical'
439 */
440 verticalClass: 'vertical',
441 /**
442 * Class applied to right-side oriented menus, Foundation default is `align-right`. Update this if using your own class.
443 * @option
444 * @type {string}
445 * @default 'align-right'
446 */
447 rightClass: 'align-right',
448 /**
449 * Boolean to force overide the clicking of links to perform default action, on second touch event for mobile.
450 * @option
451 * @type {boolean}
452 * @default true
453 */
454 forceFollow: true
455};
456
457export {DropdownMenu};