stacked_menu 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.gitignore +9 -0
- data/Gemfile +6 -0
- data/LICENSE.txt +21 -0
- data/README.md +39 -0
- data/Rakefile +2 -0
- data/bin/console +14 -0
- data/bin/setup +8 -0
- data/lib/stacked_menu/version.rb +3 -0
- data/lib/stacked_menu.rb +6 -0
- data/stacked_menu.gemspec +40 -0
- data/vendor/assets/javascripts/stacked-menu.jquery.js +43 -0
- data/vendor/assets/javascripts/stacked-menu.js +929 -0
- data/vendor/assets/stylesheets/scss/_collapsible.scss +88 -0
- data/vendor/assets/stylesheets/scss/_core.scss +292 -0
- data/vendor/assets/stylesheets/scss/_direction.scss +70 -0
- data/vendor/assets/stylesheets/scss/_hoverable.scss +83 -0
- data/vendor/assets/stylesheets/scss/_mixins.scss +50 -0
- data/vendor/assets/stylesheets/scss/_variables.scss +35 -0
- data/vendor/assets/stylesheets/scss/stacked-menu.scss +6 -0
- metadata +93 -0
@@ -0,0 +1,929 @@
|
|
1
|
+
/**
|
2
|
+
* A flexible stacked navigation menu.
|
3
|
+
* @class
|
4
|
+
*
|
5
|
+
* @example <caption>The StackedMenu basic template looks like:</caption>
|
6
|
+
* <div id="stacked-menu" class="stacked-menu">
|
7
|
+
* <nav class="menu">
|
8
|
+
* <li class="menu-item">
|
9
|
+
* <a href="home.html" class="menu-link">
|
10
|
+
* <i class="menu-icon fa fa-home"></i>
|
11
|
+
* <span class="menu-text">Home</span>
|
12
|
+
* <span class="badge badge-danger">9+</span>
|
13
|
+
* </a>
|
14
|
+
* <li>
|
15
|
+
* </nav>
|
16
|
+
* </div>
|
17
|
+
*
|
18
|
+
* @example <caption>Instance the StackedMenu:</caption>
|
19
|
+
* var menus = new StackedMenu();
|
20
|
+
*/
|
21
|
+
class StackedMenu {
|
22
|
+
|
23
|
+
/**
|
24
|
+
* Create a StackedMenu.
|
25
|
+
* @constructor
|
26
|
+
* @param {Object} options - An object containing key:value that representing the current StackedMenu.
|
27
|
+
*/
|
28
|
+
constructor(options) {
|
29
|
+
/**
|
30
|
+
* The StackedMenu options.
|
31
|
+
* @type {Object}
|
32
|
+
* @property {Boolean} compact=false - Transform StackedMenu items (except item childs) to small size.
|
33
|
+
* @property {Boolean} hoverable=false - How StackedMenu triggered `open`/`close` state. Use `false` for hoverable and `true` for collapsible (clickable).
|
34
|
+
* @property {Boolean} closeOther=true - Control whether expanding an item will cause the other items to close. Only available when `hoverable=false`.
|
35
|
+
* @property {String} align='left' - Where StackedMenu items childs will open when `hoverable=true` (`left`/`right`).
|
36
|
+
* @property {String} selector='#stacked-menu' - The StackedMenu element selector.
|
37
|
+
* @property {String} selectorClass='stacked-menu' - The css class name that will be added to the StackedMenu and used for css prefix classes.
|
38
|
+
* @example
|
39
|
+
* var options = {
|
40
|
+
* closeOther: false,
|
41
|
+
* align: 'right',
|
42
|
+
* };
|
43
|
+
*
|
44
|
+
* var menus = new StackedMenu(options);
|
45
|
+
*/
|
46
|
+
this.options = {
|
47
|
+
compact: false,
|
48
|
+
hoverable: false,
|
49
|
+
closeOther: true,
|
50
|
+
align: 'right',
|
51
|
+
selector: '#stacked-menu',
|
52
|
+
selectorClass: 'stacked-menu'
|
53
|
+
}
|
54
|
+
|
55
|
+
// mixed default and custom options
|
56
|
+
this.options = this._extend({}, this.options, options)
|
57
|
+
|
58
|
+
/**
|
59
|
+
* The StackedMenu element.
|
60
|
+
* @type {Element}
|
61
|
+
*/
|
62
|
+
this.selector = document.querySelector(this.options.selector)
|
63
|
+
|
64
|
+
/**
|
65
|
+
* The StackedMenu items.
|
66
|
+
* @type {Element}
|
67
|
+
*/
|
68
|
+
this.items = this.selector ? this.selector.querySelectorAll('.menu-item') : null
|
69
|
+
|
70
|
+
// forEach fallback
|
71
|
+
if (!Array.prototype.forEach) {
|
72
|
+
Array.prototype.forEach = function forEach(cb, arg) {
|
73
|
+
if(typeof cb !== 'function') throw new TypeError(`${cb} is not a function`)
|
74
|
+
|
75
|
+
let array = this
|
76
|
+
arg = arg || this
|
77
|
+
for(let i = 0; i < array.length; i++) {
|
78
|
+
cb.call(arg, array[i], i, array)
|
79
|
+
}
|
80
|
+
}
|
81
|
+
}
|
82
|
+
this.each = Array.prototype.forEach
|
83
|
+
|
84
|
+
/**
|
85
|
+
* Lists of feature classes that will be added to the StackedMenu depend to current options.
|
86
|
+
* Used selectorClass for prefix.
|
87
|
+
* @type {Object}
|
88
|
+
*/
|
89
|
+
this.classes = {
|
90
|
+
alignLeft: this.options.selectorClass + '-has-left',
|
91
|
+
compact: this.options.selectorClass + '-has-compact',
|
92
|
+
collapsible: this.options.selectorClass + '-has-collapsible',
|
93
|
+
hoverable: this.options.selectorClass + '-has-hoverable',
|
94
|
+
hasChild: 'has-child',
|
95
|
+
hasActive: 'has-active',
|
96
|
+
hasOpen: 'has-open'
|
97
|
+
}
|
98
|
+
|
99
|
+
/** states element */
|
100
|
+
/**
|
101
|
+
* The active item.
|
102
|
+
* @type {Element}
|
103
|
+
*/
|
104
|
+
this.active = null
|
105
|
+
|
106
|
+
/**
|
107
|
+
* The open item(s).
|
108
|
+
* @type {Element}
|
109
|
+
*/
|
110
|
+
this.open = []
|
111
|
+
|
112
|
+
/**
|
113
|
+
* The StackedMenu element.
|
114
|
+
* @type {Boolean}
|
115
|
+
*/
|
116
|
+
this.turbolinksAvailable = typeof window.Turbolinks === 'object' && window.Turbolinks.supported
|
117
|
+
|
118
|
+
/** event handlers */
|
119
|
+
this.handlerClickDoc = []
|
120
|
+
this.handlerOver = []
|
121
|
+
this.handlerOut = []
|
122
|
+
this.handlerClick = []
|
123
|
+
|
124
|
+
// Initialization
|
125
|
+
this.init()
|
126
|
+
}
|
127
|
+
|
128
|
+
/** Private methods */
|
129
|
+
/**
|
130
|
+
* Listen on document when the page is ready.
|
131
|
+
* @private
|
132
|
+
* @param {Function} handler - The callback function when page is ready.
|
133
|
+
* @return {void}
|
134
|
+
*/
|
135
|
+
_onReady(handler) {
|
136
|
+
if(document.readyState != 'loading') {
|
137
|
+
handler()
|
138
|
+
} else {
|
139
|
+
document.addEventListener('DOMContentLoaded', handler, false)
|
140
|
+
}
|
141
|
+
}
|
142
|
+
|
143
|
+
/**
|
144
|
+
* Handles clicking on menu leaves. Turbolinks friendly.
|
145
|
+
* @private
|
146
|
+
* @param {Object} self - The StackedMenu self instance.
|
147
|
+
* @return {void}
|
148
|
+
*/
|
149
|
+
_handleNavigation(self) {
|
150
|
+
self.each.call(this.items, (el) => {
|
151
|
+
self._on(el, 'click', function(e) {
|
152
|
+
// Stop propagating the event to parent links
|
153
|
+
e.stopPropagation()
|
154
|
+
// if Turbolinks are available preventDefault immediatelly.
|
155
|
+
self.turbolinksAvailable ? e.preventDefault() : null
|
156
|
+
// if the element is "parent" and Turbolinks are not available,
|
157
|
+
// maintain the original behaviour. Otherwise navigate programmatically
|
158
|
+
if (self._hasChild(el)) {
|
159
|
+
self.turbolinksAvailable ? null : e.preventDefault()
|
160
|
+
} else {
|
161
|
+
self.turbolinksAvailable ? window.Turbolinks.visit(el.firstElementChild.href) : null
|
162
|
+
}
|
163
|
+
})
|
164
|
+
})
|
165
|
+
}
|
166
|
+
|
167
|
+
|
168
|
+
/**
|
169
|
+
* Merge the contents of two or more objects together into the first object.
|
170
|
+
* @private
|
171
|
+
* @param {Object} obj - An object containing additional properties to merge in.
|
172
|
+
* @return {Object} The merged object.
|
173
|
+
*/
|
174
|
+
_extend(obj) {
|
175
|
+
obj = obj || {}
|
176
|
+
const args = arguments
|
177
|
+
for (let i = 1; i < args.length; i++) {
|
178
|
+
if (!args[i]) continue
|
179
|
+
for (let key in args[i]) {
|
180
|
+
if (args[i].hasOwnProperty(key))
|
181
|
+
obj[key] = args[i][key]
|
182
|
+
}
|
183
|
+
}
|
184
|
+
return obj
|
185
|
+
}
|
186
|
+
|
187
|
+
/**
|
188
|
+
* Attach an event to StackedMenu selector.
|
189
|
+
* @private
|
190
|
+
* @param {String} type - The name of the event (case-insensitive).
|
191
|
+
* @param {(Boolean|Number|String|Array|Object)} data - The custom data that will be added to event.
|
192
|
+
* @return {void}
|
193
|
+
*/
|
194
|
+
_emit(type, data) {
|
195
|
+
let e
|
196
|
+
if (document.createEvent) {
|
197
|
+
e = document.createEvent('Event')
|
198
|
+
e.initEvent(type, true, true)
|
199
|
+
} else {
|
200
|
+
e = document.createEventObject()
|
201
|
+
e.eventType = type
|
202
|
+
}
|
203
|
+
e.eventName = type
|
204
|
+
e.data = data || this
|
205
|
+
// attach event to selector
|
206
|
+
document.createEvent
|
207
|
+
? this.selector.dispatchEvent(e)
|
208
|
+
: this.selector.fireEvent('on' + type, e)
|
209
|
+
}
|
210
|
+
|
211
|
+
/**
|
212
|
+
* Bind one or two handlers to the element, to be executed when the mouse pointer enters and leaves the element.
|
213
|
+
* @private
|
214
|
+
* @param {Element} el - The target element.
|
215
|
+
* @param {Function} handlerOver - A function to execute when the mouse pointer enters the element.
|
216
|
+
* @param {Function} handlerOut - A function to execute when the mouse pointer leaves the element.
|
217
|
+
* @return {void}
|
218
|
+
*/
|
219
|
+
_hover(el, handlerOver, handlerOut) {
|
220
|
+
if (el.tagName === 'A') {
|
221
|
+
this._on(el, 'focus', handlerOver)
|
222
|
+
this._on(el, 'blur', handlerOut)
|
223
|
+
} else {
|
224
|
+
this._on(el, 'mouseover', handlerOver)
|
225
|
+
this._on(el, 'mouseout', handlerOut)
|
226
|
+
}
|
227
|
+
}
|
228
|
+
|
229
|
+
/**
|
230
|
+
* Registers the specified listener on the element.
|
231
|
+
* @private
|
232
|
+
* @param {Element} el - The target element.
|
233
|
+
* @param {String} type - The name of the event.
|
234
|
+
* @param {Function} handler - The callback function when event type is fired.
|
235
|
+
* @return {void}
|
236
|
+
*/
|
237
|
+
_on(el, type, handler) {
|
238
|
+
let types = type.split(' ')
|
239
|
+
for (let i = 0; i < types.length; i++) {
|
240
|
+
el[window.addEventListener ? 'addEventListener' : 'attachEvent']( window.addEventListener ? types[i] : `on${types[i]}` , handler, false)
|
241
|
+
}
|
242
|
+
}
|
243
|
+
|
244
|
+
/**
|
245
|
+
* Removes the event listener previously registered with [_on()]{@link StackedMenu#_on} method.
|
246
|
+
* @private
|
247
|
+
* @param {Element} el - The target element.
|
248
|
+
* @param {String} type - The name of the event.
|
249
|
+
* @param {Function} handler - The callback function when event type is fired.
|
250
|
+
* @return {void}
|
251
|
+
*/
|
252
|
+
_off(el, type, handler) {
|
253
|
+
let types = type.split(' ')
|
254
|
+
for (let i = 0; i < types.length; i++) {
|
255
|
+
el[window.removeEventListener ? 'removeEventListener' : 'detachEvent']( window.removeEventListener ? types[i] : `on${types[i]}` , handler, false)
|
256
|
+
}
|
257
|
+
}
|
258
|
+
|
259
|
+
/**
|
260
|
+
* Adds one or more class names to the target element.
|
261
|
+
* @private
|
262
|
+
* @param {Element} el - The target element.
|
263
|
+
* @param {String} className - Specifies one or more class names to be added.
|
264
|
+
* @return {void}
|
265
|
+
*/
|
266
|
+
_addClass(el, className) {
|
267
|
+
let classes = className.split(' ')
|
268
|
+
for (let i = 0; i < classes.length; i++) {
|
269
|
+
if (el.classList) el.classList.add(classes[i])
|
270
|
+
else el.classes[i] += ' ' + classes[i]
|
271
|
+
}
|
272
|
+
}
|
273
|
+
|
274
|
+
/**
|
275
|
+
* Removes one or more class names to the target element.
|
276
|
+
* @private
|
277
|
+
* @param {Element} el - The target element.
|
278
|
+
* @param {String} className - Specifies one or more class names to be added.
|
279
|
+
* @return {void}
|
280
|
+
*/
|
281
|
+
_removeClass(el, className) {
|
282
|
+
let classes = className.split(' ')
|
283
|
+
for (let i = 0; i < classes.length; i++) {
|
284
|
+
if (el.classList) el.classList.remove(classes[i])
|
285
|
+
else el.classes[i] = el.classes[i].replace(new RegExp('(^|\\b)' + classes[i].split(' ').join('|') + '(\\b|$)', 'gi'), ' ')
|
286
|
+
}
|
287
|
+
}
|
288
|
+
|
289
|
+
/**
|
290
|
+
* Determine whether the element is assigned the given class.
|
291
|
+
* @private
|
292
|
+
* @param {Element} el - The target element.
|
293
|
+
* @param {String} className - The class name to search for.
|
294
|
+
* @return {Boolean} is has className.
|
295
|
+
*/
|
296
|
+
_hasClass(el, className) {
|
297
|
+
if (el.classList) return el.classList.contains(className)
|
298
|
+
return new RegExp('(^| )' + className + '( |$)', 'gi').test(el.className)
|
299
|
+
}
|
300
|
+
|
301
|
+
/**
|
302
|
+
* Determine whether the element is a menu child.
|
303
|
+
* @private
|
304
|
+
* @param {Element} el - The target element.
|
305
|
+
* @return {Boolean} is has child.
|
306
|
+
*/
|
307
|
+
_hasChild(el) {
|
308
|
+
return this._hasClass(el, this.classes.hasChild)
|
309
|
+
}
|
310
|
+
|
311
|
+
/**
|
312
|
+
* Determine whether the element is a active menu.
|
313
|
+
* @private
|
314
|
+
* @param {Element} el - The target element.
|
315
|
+
* @return {Boolean} is has active state.
|
316
|
+
*/
|
317
|
+
_hasActive(el) {
|
318
|
+
return this._hasClass(el, this.classes.hasActive)
|
319
|
+
}
|
320
|
+
|
321
|
+
/**
|
322
|
+
* Determine whether the element is a open menu.
|
323
|
+
* @private
|
324
|
+
* @param {Element} el - The target element.
|
325
|
+
* @return {Boolean} is has open state.
|
326
|
+
*/
|
327
|
+
_hasOpen(el) {
|
328
|
+
return this._hasClass(el, this.classes.hasOpen)
|
329
|
+
}
|
330
|
+
|
331
|
+
/**
|
332
|
+
* Determine whether the element is a level menu.
|
333
|
+
* @private
|
334
|
+
* @param {Element} el - The target element.
|
335
|
+
* @return {Boolean} is a level menu.
|
336
|
+
*/
|
337
|
+
_isLevelMenu (el) {
|
338
|
+
return this._hasClass(el.parentNode.parentNode, this.options.selectorClass)
|
339
|
+
}
|
340
|
+
|
341
|
+
/**
|
342
|
+
* Attach an event to menu item depend on hoverable option.
|
343
|
+
* @private
|
344
|
+
* @param {Element} el - The target element.
|
345
|
+
* @param {Number} index - An array index from each menu item use to detach the current event.
|
346
|
+
* @return {void}
|
347
|
+
*/
|
348
|
+
_menuTrigger(el, index) {
|
349
|
+
let elHover = el.querySelector('a')
|
350
|
+
|
351
|
+
// remove exist listener
|
352
|
+
this._off(el, 'mouseover', this.handlerOver[index])
|
353
|
+
this._off(el, 'mouseout', this.handlerOut[index])
|
354
|
+
this._off(elHover, 'focus', this.handlerOver[index])
|
355
|
+
this._off(elHover, 'blur', this.handlerOut[index])
|
356
|
+
this._off(el, 'click', this.handlerClick[index])
|
357
|
+
|
358
|
+
// handler listener
|
359
|
+
this.handlerOver[index] = this.openMenu.bind(this, el)
|
360
|
+
this.handlerOut[index] = this.closeMenu.bind(this, el)
|
361
|
+
this.handlerClick[index] = this.toggleMenu.bind(this, el)
|
362
|
+
|
363
|
+
// add listener
|
364
|
+
if (this.isHoverable()) {
|
365
|
+
if (this._hasChild(el)) {
|
366
|
+
this._hover(el, this.handlerOver[index], this.handlerOut[index])
|
367
|
+
this._hover(elHover, this.handlerOver[index], this.handlerOut[index])
|
368
|
+
}
|
369
|
+
} else {
|
370
|
+
this._on(el, 'click', this.handlerClick[index])
|
371
|
+
}
|
372
|
+
}
|
373
|
+
|
374
|
+
/**
|
375
|
+
* Handle for menu items interactions.
|
376
|
+
* @private
|
377
|
+
* @param {Element} items - The element of menu items.
|
378
|
+
* @return {void}
|
379
|
+
*/
|
380
|
+
_handleInteractions(items) {
|
381
|
+
const self = this
|
382
|
+
|
383
|
+
this.each.call(items, (el, i) => {
|
384
|
+
if (self._hasChild(el)) {
|
385
|
+
self._menuTrigger(el, i)
|
386
|
+
}
|
387
|
+
|
388
|
+
if(self._hasActive(el)) self.active = el
|
389
|
+
})
|
390
|
+
}
|
391
|
+
|
392
|
+
/**
|
393
|
+
* Get the parent menu item text of menu to be use on menu subhead.
|
394
|
+
* @private
|
395
|
+
* @param {Element} el - The target element.
|
396
|
+
* @return {void}
|
397
|
+
*/
|
398
|
+
_getSubhead(el) {
|
399
|
+
return el.querySelector('.menu-text').textContent
|
400
|
+
}
|
401
|
+
|
402
|
+
/**
|
403
|
+
* Generate the subhead element for each child menu.
|
404
|
+
* @private
|
405
|
+
* @return {void}
|
406
|
+
*/
|
407
|
+
_generateSubhead() {
|
408
|
+
const self = this
|
409
|
+
let menus = this.selector.children
|
410
|
+
let link, menu, subhead, label
|
411
|
+
this.each.call(menus, el => {
|
412
|
+
self.each.call(el.children, child => {
|
413
|
+
if (self._hasChild(child)) {
|
414
|
+
self.each.call(child.children, cc => {
|
415
|
+
if(self._hasClass(cc, 'menu-link')) link = cc
|
416
|
+
})
|
417
|
+
|
418
|
+
menu = link.nextElementSibling
|
419
|
+
subhead = document.createElement('li')
|
420
|
+
label = document.createTextNode(self._getSubhead(link))
|
421
|
+
subhead.appendChild(label)
|
422
|
+
self._addClass(subhead, 'menu-subhead')
|
423
|
+
|
424
|
+
menu.insertBefore(subhead, menu.firstChild)
|
425
|
+
}
|
426
|
+
})
|
427
|
+
})
|
428
|
+
}
|
429
|
+
|
430
|
+
/**
|
431
|
+
* Handle menu link tabindex depend on parent states.
|
432
|
+
* @return {void}
|
433
|
+
*/
|
434
|
+
_handleTabIndex () {
|
435
|
+
const self = this
|
436
|
+
this.each.call(this.items, el => {
|
437
|
+
let container = el.parentNode.parentNode
|
438
|
+
if (!self._isLevelMenu(el)) {
|
439
|
+
el.querySelector('a').setAttribute('tabindex', '-1')
|
440
|
+
}
|
441
|
+
if (self._hasActive(container) || self._hasOpen(container)) {
|
442
|
+
el.querySelector('a').removeAttribute('tabindex')
|
443
|
+
}
|
444
|
+
})
|
445
|
+
}
|
446
|
+
|
447
|
+
/**
|
448
|
+
* Animate slide menu item.
|
449
|
+
* @private
|
450
|
+
* @param {Object} el - The target element.
|
451
|
+
* @param {String} direction - Up/Down slide direction.
|
452
|
+
* @param {Number} speed - Animation Speed in millisecond.
|
453
|
+
* @param {String} easing - CSS Animation effect.
|
454
|
+
* @return {Promise} resolve
|
455
|
+
*/
|
456
|
+
_slide(el, direction, speed, easing) {
|
457
|
+
speed = speed || 300
|
458
|
+
easing = easing || 'ease'
|
459
|
+
let self = this
|
460
|
+
let menu = el.querySelector('.menu')
|
461
|
+
let es = window.getComputedStyle(el)['height']
|
462
|
+
// wait to resolve
|
463
|
+
let walkSpeed = speed + 50
|
464
|
+
// wait to clean style attribute
|
465
|
+
let clearSpeed = walkSpeed + 100
|
466
|
+
|
467
|
+
menu.style.transition = `height ${speed}ms ${easing}, opacity ${speed/2}ms ${easing}, visibility ${speed/2}ms ${easing}`
|
468
|
+
|
469
|
+
// slideDown
|
470
|
+
if (direction === 'down') {
|
471
|
+
// element
|
472
|
+
el.style.overflow = 'hidden'
|
473
|
+
el.style.height = es
|
474
|
+
// menu
|
475
|
+
menu.style.height = 'auto'
|
476
|
+
// get the current menu height
|
477
|
+
let height = window.getComputedStyle(menu)['height']
|
478
|
+
menu.style.height = 0
|
479
|
+
menu.style.visibility = 'hidden'
|
480
|
+
menu.style.opacity = 0
|
481
|
+
// remove element style
|
482
|
+
el.style.overflow = ''
|
483
|
+
el.style.height = ''
|
484
|
+
|
485
|
+
setTimeout(function() {
|
486
|
+
menu.style.height = height
|
487
|
+
menu.style.opacity = 1
|
488
|
+
menu.style.visibility = 'visible'
|
489
|
+
}, 0)
|
490
|
+
} else if (direction === 'up') {
|
491
|
+
// get the menu height
|
492
|
+
let height = window.getComputedStyle(menu)['height']
|
493
|
+
menu.style.height = height
|
494
|
+
menu.style.visibility = 'visible'
|
495
|
+
menu.style.opacity = 1
|
496
|
+
|
497
|
+
setTimeout(function() {
|
498
|
+
menu.style.height = 0
|
499
|
+
menu.style.visibility = 'hidden'
|
500
|
+
menu.style.opacity = 0
|
501
|
+
}, 0)
|
502
|
+
}
|
503
|
+
|
504
|
+
let done = new Promise(function(resolve) {
|
505
|
+
// remove the temporary styles
|
506
|
+
setTimeout(function() {
|
507
|
+
resolve(el)
|
508
|
+
// emit event
|
509
|
+
self._emit('menu:slide' + direction)
|
510
|
+
}, walkSpeed)
|
511
|
+
})
|
512
|
+
|
513
|
+
// remove styles after done has resolve
|
514
|
+
setTimeout(function() {
|
515
|
+
menu.removeAttribute('style')
|
516
|
+
}, clearSpeed)
|
517
|
+
|
518
|
+
return done
|
519
|
+
}
|
520
|
+
|
521
|
+
/** Public methods */
|
522
|
+
/**
|
523
|
+
* The first process that called after constructs the StackedMenu instance.
|
524
|
+
* @public
|
525
|
+
* @fires StackedMenu#menu:init
|
526
|
+
* @return {void}
|
527
|
+
*/
|
528
|
+
init() {
|
529
|
+
const self = this
|
530
|
+
let opts = this.options
|
531
|
+
|
532
|
+
this._addClass(this.selector, opts.selectorClass)
|
533
|
+
|
534
|
+
// generate subhead
|
535
|
+
this._generateSubhead()
|
536
|
+
|
537
|
+
// implement compact feature
|
538
|
+
this.compact(opts.compact)
|
539
|
+
// implement hoverable feature
|
540
|
+
this.hoverable(opts.hoverable)
|
541
|
+
|
542
|
+
// handle menu link tabindex
|
543
|
+
this._handleTabIndex()
|
544
|
+
|
545
|
+
// handle menu click with or without Turbolinks
|
546
|
+
this._handleNavigation(self)
|
547
|
+
|
548
|
+
// close on outside click, only on collapsible with compact mode
|
549
|
+
this._on(document.body, 'click', function () {
|
550
|
+
if (!self.isHoverable() && self.isCompact()) {
|
551
|
+
// handle listener
|
552
|
+
self.closeAllMenu()
|
553
|
+
}
|
554
|
+
})
|
555
|
+
|
556
|
+
// on ready state
|
557
|
+
this._onReady(() => {
|
558
|
+
|
559
|
+
/**
|
560
|
+
* This event is fired when the Menu has completed init.
|
561
|
+
*
|
562
|
+
* @event StackedMenu#menu:init
|
563
|
+
* @type {Object}
|
564
|
+
* @property {Object} data - The StackedMenu data instance.
|
565
|
+
*
|
566
|
+
* @example
|
567
|
+
* document.querySelector('#stacked-menu').addEventListener('menu:init', function(e) {
|
568
|
+
* console.log(e.data);
|
569
|
+
* });
|
570
|
+
* @example <caption>Or using jQuery:</caption>
|
571
|
+
* $('#stacked-menu').on('menu:init', function() {
|
572
|
+
* console.log('fired on menu:init!!');
|
573
|
+
* });
|
574
|
+
*/
|
575
|
+
self._emit('menu:init')
|
576
|
+
})
|
577
|
+
}
|
578
|
+
|
579
|
+
/**
|
580
|
+
* Open/show the target menu item. This method didn't take effect to an active item if not on compact mode.
|
581
|
+
* @public
|
582
|
+
* @fires StackedMenu#menu:open
|
583
|
+
* @param {Element} el - The target element.
|
584
|
+
* @param {Boolean} emiter - are the element will fire menu:open or not.
|
585
|
+
* @return {Object} The StackedMenu instance.
|
586
|
+
*
|
587
|
+
* @example
|
588
|
+
* var menuItem2 = menu.querySelectorAll('.menu-item.has-child')[1];
|
589
|
+
* menu.openMenu(menuItem2);
|
590
|
+
*/
|
591
|
+
openMenu(el, emiter = true) {
|
592
|
+
// prevent open on active item if not on compact mode
|
593
|
+
if(this._hasActive(el) && !this.isCompact()) return
|
594
|
+
const self = this
|
595
|
+
let blockedSlide = this._isLevelMenu(el) && this.isCompact()
|
596
|
+
|
597
|
+
// open menu
|
598
|
+
if (this.isHoverable() || blockedSlide) {
|
599
|
+
this._addClass(el, this.classes.hasOpen)
|
600
|
+
// handle tabindex
|
601
|
+
this._handleTabIndex()
|
602
|
+
} else {
|
603
|
+
// slide down
|
604
|
+
this._slide(el, 'down', 150, 'linear').then(function() {
|
605
|
+
self._addClass(el, self.classes.hasOpen)
|
606
|
+
// handle tabindex
|
607
|
+
self._handleTabIndex()
|
608
|
+
})
|
609
|
+
}
|
610
|
+
|
611
|
+
this.open.push(el)
|
612
|
+
|
613
|
+
// child menu behavior
|
614
|
+
if (this.isHoverable() || (this.isCompact() && !this.hoverable())) {
|
615
|
+
const clientHeight = document.documentElement.clientHeight
|
616
|
+
const child = el.querySelector('.menu')
|
617
|
+
const pos = child.getBoundingClientRect()
|
618
|
+
const tolerance = pos.height - 20
|
619
|
+
const bottom = clientHeight - pos.top
|
620
|
+
const transformOriginX = this.options.align === 'left' ? '100%' : '0px'
|
621
|
+
|
622
|
+
if (pos.top >= 500 || tolerance >= bottom) {
|
623
|
+
child.style.top = 'auto'
|
624
|
+
child.style.bottom = 0
|
625
|
+
child.style.transformOrigin = `${transformOriginX} 100% 0`
|
626
|
+
}
|
627
|
+
}
|
628
|
+
|
629
|
+
/**
|
630
|
+
* This event is fired when the Menu has open.
|
631
|
+
*
|
632
|
+
* @event StackedMenu#menu:open
|
633
|
+
* @type {Object}
|
634
|
+
* @property {Object} data - The StackedMenu data instance.
|
635
|
+
*
|
636
|
+
* @example
|
637
|
+
* document.querySelector('#stacked-menu').addEventListener('menu:open', function(e) {
|
638
|
+
* console.log(e.data);
|
639
|
+
* });
|
640
|
+
* @example <caption>Or using jQuery:</caption>
|
641
|
+
* $('#stacked-menu').on('menu:open', function() {
|
642
|
+
* console.log('fired on menu:open!!');
|
643
|
+
* });
|
644
|
+
*/
|
645
|
+
if (emiter) {
|
646
|
+
this._emit('menu:open')
|
647
|
+
}
|
648
|
+
|
649
|
+
return this
|
650
|
+
}
|
651
|
+
|
652
|
+
/**
|
653
|
+
* Close/hide the target menu item.
|
654
|
+
* @public
|
655
|
+
* @fires StackedMenu#menu:close
|
656
|
+
* @param {Element} el - The target element.
|
657
|
+
* @param {Boolean} emiter - are the element will fire menu:open or not.
|
658
|
+
* @return {Object} The StackedMenu instance.
|
659
|
+
*
|
660
|
+
* @example
|
661
|
+
* var menuItem2 = menu.querySelectorAll('.menu-item.has-child')[1];
|
662
|
+
* menu.closeMenu(menuItem2);
|
663
|
+
*/
|
664
|
+
closeMenu(el, emiter = true) {
|
665
|
+
const self = this
|
666
|
+
let blockedSlide = this._isLevelMenu(el) && this.isCompact()
|
667
|
+
// open menu
|
668
|
+
if (this.isHoverable() || blockedSlide) {
|
669
|
+
this._removeClass(el, this.classes.hasOpen)
|
670
|
+
// handle tabindex
|
671
|
+
this._handleTabIndex()
|
672
|
+
} else {
|
673
|
+
if (!this._hasActive(el)) {
|
674
|
+
// slide up
|
675
|
+
this._slide(el, 'up', 150, 'linear').then(function() {
|
676
|
+
self._removeClass(el, self.classes.hasOpen)
|
677
|
+
// handle tabindex
|
678
|
+
self._handleTabIndex()
|
679
|
+
})
|
680
|
+
}
|
681
|
+
}
|
682
|
+
|
683
|
+
this.each.call(this.open, (v, i) => {
|
684
|
+
if (el == v) self.open.splice(i, 1)
|
685
|
+
})
|
686
|
+
|
687
|
+
// remove child menu behavior style
|
688
|
+
if (this.isHoverable() || (this.isCompact() && !this.hoverable())) {
|
689
|
+
const child = el.querySelector('.menu')
|
690
|
+
|
691
|
+
child.style.top = ''
|
692
|
+
child.style.bottom = ''
|
693
|
+
child.style.transformOrigin = ''
|
694
|
+
}
|
695
|
+
|
696
|
+
/**
|
697
|
+
* This event is fired when the Menu has close.
|
698
|
+
*
|
699
|
+
* @event StackedMenu#menu:close
|
700
|
+
* @type {Object}
|
701
|
+
* @property {Object} data - The StackedMenu data instance.
|
702
|
+
*
|
703
|
+
* @example
|
704
|
+
* document.querySelector('#stacked-menu').addEventListener('menu:close', function(e) {
|
705
|
+
* console.log(e.data);
|
706
|
+
* });
|
707
|
+
* @example <caption>Or using jQuery:</caption>
|
708
|
+
* $('#stacked-menu').on('menu:close', function() {
|
709
|
+
* console.log('fired on menu:close!!');
|
710
|
+
* });
|
711
|
+
*/
|
712
|
+
if (emiter) {
|
713
|
+
this._emit('menu:close')
|
714
|
+
}
|
715
|
+
|
716
|
+
return this
|
717
|
+
}
|
718
|
+
|
719
|
+
/**
|
720
|
+
* Close all opened menu items.
|
721
|
+
* @public
|
722
|
+
* @fires StackedMenu#menu:close
|
723
|
+
* @return {Object} The StackedMenu instance.
|
724
|
+
*
|
725
|
+
* @example
|
726
|
+
* menu.closeAllMenu();
|
727
|
+
*/
|
728
|
+
closeAllMenu() {
|
729
|
+
const self = this
|
730
|
+
this.each.call(this.items, el => {
|
731
|
+
if (self._hasOpen(el)) {
|
732
|
+
self.closeMenu(el, false)
|
733
|
+
}
|
734
|
+
})
|
735
|
+
|
736
|
+
return this
|
737
|
+
}
|
738
|
+
|
739
|
+
/**
|
740
|
+
* Toggle open/close the target menu item.
|
741
|
+
* @public
|
742
|
+
* @fires StackedMenu#menu:open
|
743
|
+
* @fires StackedMenu#menu:close
|
744
|
+
* @param {Element} el - The target element.
|
745
|
+
* @return {Object} The StackedMenu instance.
|
746
|
+
*
|
747
|
+
* @example
|
748
|
+
* var menuItem2 = menu.querySelectorAll('.menu-item.has-child')[1];
|
749
|
+
* menu.toggleMenu(menuItem2);
|
750
|
+
*/
|
751
|
+
toggleMenu(el) {
|
752
|
+
const method = this._hasOpen(el) ? 'closeMenu': 'openMenu'
|
753
|
+
const self = this
|
754
|
+
let itemParent, elParent
|
755
|
+
|
756
|
+
// close other
|
757
|
+
this.each.call(this.items, item => {
|
758
|
+
itemParent = item.parentNode.parentNode
|
759
|
+
itemParent = self._hasClass(itemParent, 'menu-item') ? itemParent : itemParent.parentNode
|
760
|
+
elParent = el.parentNode.parentNode
|
761
|
+
elParent = self._hasClass(elParent, 'menu-item') ? elParent : elParent.parentNode
|
762
|
+
|
763
|
+
// close other except parents that has open state and an active item
|
764
|
+
if(!self._hasOpen(elParent) && self._hasChild(itemParent)) {
|
765
|
+
if (self.options.closeOther || (!self.options.closeOther && self.isCompact())) {
|
766
|
+
if (self._hasOpen(itemParent)) {
|
767
|
+
self.closeMenu(itemParent, false)
|
768
|
+
}
|
769
|
+
}
|
770
|
+
}
|
771
|
+
})
|
772
|
+
// open target el
|
773
|
+
if (this._hasChild(el)) this[method](el)
|
774
|
+
|
775
|
+
return this
|
776
|
+
}
|
777
|
+
|
778
|
+
/**
|
779
|
+
* Set the open menu position to `left` or `right`.
|
780
|
+
* @public
|
781
|
+
* @fires StackedMenu#menu:align
|
782
|
+
* @param {String} position - The position that will be set to the Menu.
|
783
|
+
* @return {Object} The StackedMenu instance.
|
784
|
+
*
|
785
|
+
* @example
|
786
|
+
* menu.align('left');
|
787
|
+
*/
|
788
|
+
align(position) {
|
789
|
+
const method = (position === 'left') ? '_addClass': '_removeClass'
|
790
|
+
const classes = this.classes
|
791
|
+
|
792
|
+
this[method](this.selector, classes.alignLeft)
|
793
|
+
|
794
|
+
this.options.align = position
|
795
|
+
|
796
|
+
/**
|
797
|
+
* This event is fired when the Menu has changed align position.
|
798
|
+
*
|
799
|
+
* @event StackedMenu#menu:align
|
800
|
+
* @type {Object}
|
801
|
+
* @property {Object} data - The StackedMenu data instance.
|
802
|
+
*
|
803
|
+
* @example
|
804
|
+
* document.querySelector('#stacked-menu').addEventListener('menu:align', function(e) {
|
805
|
+
* console.log(e.data);
|
806
|
+
* });
|
807
|
+
* @example <caption>Or using jQuery:</caption>
|
808
|
+
* $('#stacked-menu').on('menu:align', function() {
|
809
|
+
* console.log('fired on menu:align!!');
|
810
|
+
* });
|
811
|
+
*/
|
812
|
+
this._emit('menu:align')
|
813
|
+
|
814
|
+
return this
|
815
|
+
}
|
816
|
+
|
817
|
+
/**
|
818
|
+
* Determine whether the Menu is currently compact.
|
819
|
+
* @public
|
820
|
+
* @return {Boolean} is compact.
|
821
|
+
*
|
822
|
+
* @example
|
823
|
+
* var isCompact = menu.isCompact();
|
824
|
+
*/
|
825
|
+
isCompact() {
|
826
|
+
return this.options.compact
|
827
|
+
}
|
828
|
+
|
829
|
+
/**
|
830
|
+
* Toggle the Menu compact mode.
|
831
|
+
* @public
|
832
|
+
* @fires StackedMenu#menu:compact
|
833
|
+
* @param {Boolean} isCompact - The compact mode.
|
834
|
+
* @return {Object} The StackedMenu instance.
|
835
|
+
*
|
836
|
+
* @example
|
837
|
+
* menu.compact(true);
|
838
|
+
*/
|
839
|
+
compact(isCompact) {
|
840
|
+
const method = (isCompact) ? '_addClass': '_removeClass'
|
841
|
+
const classes = this.classes
|
842
|
+
|
843
|
+
this[method](this.selector, classes.compact)
|
844
|
+
|
845
|
+
this.options.compact = isCompact
|
846
|
+
// reset interactions
|
847
|
+
this._handleInteractions(this.items)
|
848
|
+
|
849
|
+
/**
|
850
|
+
* This event is fired when the Menu has completed toggle compact mode.
|
851
|
+
*
|
852
|
+
* @event StackedMenu#menu:compact
|
853
|
+
* @type {Object}
|
854
|
+
* @property {Object} data - The StackedMenu data instance.
|
855
|
+
*
|
856
|
+
* @example
|
857
|
+
* document.querySelector('#stacked-menu').addEventListener('menu:compact', function(e) {
|
858
|
+
* console.log(e.data);
|
859
|
+
* });
|
860
|
+
* @example <caption>Or using jQuery:</caption>
|
861
|
+
* $('#stacked-menu').on('menu:compact', function() {
|
862
|
+
* console.log('fired on menu:compact!!');
|
863
|
+
* });
|
864
|
+
*/
|
865
|
+
this._emit('menu:compact')
|
866
|
+
|
867
|
+
return this
|
868
|
+
}
|
869
|
+
|
870
|
+
/**
|
871
|
+
* Determine whether the Menu is currently hoverable.
|
872
|
+
* @public
|
873
|
+
* @return {Boolean} is hoverable.
|
874
|
+
*
|
875
|
+
* @example
|
876
|
+
* var isHoverable = menu.isHoverable();
|
877
|
+
*/
|
878
|
+
isHoverable() {
|
879
|
+
return this.options.hoverable
|
880
|
+
}
|
881
|
+
|
882
|
+
/**
|
883
|
+
* Toggle the Menu (interaction) hoverable.
|
884
|
+
* @public
|
885
|
+
* @fires StackedMenu#menu:hoverable
|
886
|
+
* @param {Boolean} isHoverable - `true` for hoverable and `false` for collapsible (clickable).
|
887
|
+
* @return {Object} The StackedMenu instance.
|
888
|
+
*
|
889
|
+
* @example
|
890
|
+
* menu.hoverable(true);
|
891
|
+
*/
|
892
|
+
hoverable(isHoverable) {
|
893
|
+
const classes = this.classes
|
894
|
+
|
895
|
+
if (isHoverable) {
|
896
|
+
this._addClass(this.selector, classes.hoverable)
|
897
|
+
this._removeClass(this.selector, classes.collapsible)
|
898
|
+
} else {
|
899
|
+
this._addClass(this.selector, classes.collapsible)
|
900
|
+
this._removeClass(this.selector, classes.hoverable)
|
901
|
+
}
|
902
|
+
|
903
|
+
this.options.hoverable = isHoverable
|
904
|
+
// reset interactions
|
905
|
+
this._handleInteractions(this.items)
|
906
|
+
|
907
|
+
/**
|
908
|
+
* This event is fired when the Menu has completed toggle hoverable.
|
909
|
+
*
|
910
|
+
* @event StackedMenu#menu:hoverable
|
911
|
+
* @type {Object}
|
912
|
+
* @property {Object} data - The StackedMenu data instance.
|
913
|
+
*
|
914
|
+
* @example
|
915
|
+
* document.querySelector('#stacked-menu').addEventListener('menu:hoverable', function(e) {
|
916
|
+
* console.log(e.data);
|
917
|
+
* });
|
918
|
+
* @example <caption>Or using jQuery:</caption>
|
919
|
+
* $('#stacked-menu').on('menu:hoverable', function() {
|
920
|
+
* console.log('fired on menu:hoverable!!');
|
921
|
+
* });
|
922
|
+
*/
|
923
|
+
this._emit('menu:hoverable')
|
924
|
+
|
925
|
+
return this
|
926
|
+
}
|
927
|
+
}
|
928
|
+
|
929
|
+
export default StackedMenu
|