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