themes/default/scripts/elk_menu.js   F
last analyzed

Complexity

Total Complexity 63
Complexity/F 2.42

Size

Lines of Code 363
Function Count 26

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 190
dl 0
loc 363
rs 3.36
c 0
b 0
f 0
wmc 63
mnd 37
bc 37
fnc 26
bpm 1.423
cpm 2.423
noi 1

How to fix   Complexity   

Complexity

Complex classes like themes/default/scripts/elk_menu.js often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

1
/*!
2
 * @package   ElkArte Forum
3
 * @copyright ElkArte Forum contributors
4
 * @license   BSD http://opensource.org/licenses/BSD-3-Clause (see accompanying LICENSE.txt file)
5
 *
6
 * @version 2.0 Beta 1
7
 */
8
9
/**
10
 * Menu functions to allow touchscreen / keyboard interaction in place of hover/mouse
11
 *
12
 * @param {string|HTMLElement} menuRef CSS selector of the top-level UL, or the UL element itself
13
 */
14
function elkMenu (menuRef)
15
{
16
    // Accept either a selector string or a concrete element
17
	this.menu = null;
18
    if (typeof menuRef === 'string')
19
    {
20
        this.menu = document.querySelector(menuRef);
21
    }
22
    else if (menuRef && menuRef.nodeType === 1)
23
    {
24
        this.menu = menuRef;
25
    }
26
27
    if (this.menu !== null)
28
    {
29
        // Prevent double-initialization on the same menu element
30
        if (!this.menu.dataset.elkMenuInit)
31
        {
32
            this.menu.dataset.elkMenuInit = '1';
33
            this.initMenu();
34
        }
35
    }
36
}
37
38
/**
39
 * Setup the menu to work with click / keyboard events instead of :hover
40
 */
41
elkMenu.prototype.initMenu = function() {
42
	// Setup enter/spacebar keys to trigger a click on the "Skip to main content" link
43
	if (this.menu.id === 'main_menu')
44
	{
45
		const skip = document.getElementById('skipnav');
46
		if (skip)
47
		{
48
			this.keysAsClick(skip);
49
		}
50
	}
51
52
	// Removing this class prevents the standard hover effect, assuming the CSS is set up correctly
53
	this.menu.classList.remove('no_js');
54
	if (this.menu.parentElement)
55
	{
56
		this.menu.parentElement.classList.remove('no_js');
57
	}
58
59
	// The subMenus (ul.menulevel#)
60
	let subMenu = this.menu.querySelectorAll('a + ul');
61
62
	// Initial aria-hidden = true for all subMenus
63
	subMenu.forEach(function(item) {
64
		item.setAttribute('aria-hidden', 'true');
65
	});
66
67
	// Document level events to close dropdowns on page click or ESC
68
	// Attach these only once per document to avoid duplicate handlers when multiple menus are initialized
69
	if (!document.body.dataset.elkMenuDocHandlers)
70
	{
71
		document.body.dataset.elkMenuDocHandlers = '1';
72
		this.docKeydown();
73
		this.docClick();
74
	}
75
76
	// Setup the subMenus (menulevel2, menulevel3) to open when clicked
77
	this.submenuReveal(subMenu);
78
};
79
80
/**
81
 * CLose menu when clicked outside its structure
82
 */
83
elkMenu.prototype.docClick = function() {
84
    document.body.addEventListener('click', function(e) {
85
        // For each initialized menu on the page, decide if it should be closed
86
        document.querySelectorAll('[data-elk-menu-init="1"]').forEach(function(menuEl) {
87
            // Clicked outside of this menu instance
88
            if (!menuEl.contains(e.target))
89
            {
90
                this.resetMenu(menuEl);
91
                return;
92
            }
93
94
            // Clicked inside the menu hierarchy, but not on a link (on UL)
95
            if (e.target && e.target.tagName && e.target.tagName.toLowerCase() === 'ul')
96
            {
97
                this.resetMenu(menuEl);
98
            }
99
        }.bind(this));
100
    }.bind(this));
101
};
102
103
/**
104
 * Pressed the escape key, close any open dropdowns.  This will fire
105
 * if a menu/submenu does not capture the keyboard event first, as if they
106
 * are not open or lost focus.
107
 */
108
elkMenu.prototype.docKeydown = function() {
109
    document.body.addEventListener('keydown', function(e) {
110
        e = e || window.e;
111
        if (e.key === 'Escape')
112
        {
113
            // Close all initialized menus on Escape
114
            document.querySelectorAll('[data-elk-menu-init="1"]').forEach(function(menuEl) {
115
                this.resetMenu(menuEl);
116
            }.bind(this));
117
        }
118
    }.bind(this));
119
};
120
121
/**
122
 * Sets class and aria values for open or closed submenus.  Prevents default
123
 * action on links that are both disclose and navigation by revealing on the
124
 * first click and then following on the second.
125
 *
126
 * @param {NodeListOf} subMenu
127
 */
128
elkMenu.prototype.submenuReveal = function(subMenu) {
129
	// All the subMenus menulevel2, menulevel3
130
	Array.prototype.forEach.call(subMenu, function(menu) {
131
		// The menu items container LI and link LI > A
132
		let parentLi = menu.parentNode,
133
			subLink = parentLi ? parentLi.querySelector('a') : null;
134
135
		if (!parentLi || !subLink)
136
		{
137
			return; // malformed structure, skip
138
		}
139
140
		// Initial aria and role for each submenu trigger
141
		this.SetItemAttribute(subLink, '', {
142
			'role': 'button',
143
			'aria-pressed': 'false',
144
			'aria-expanded': 'false'
145
		});
146
147
		// Setup keyboard navigation
148
		this.keysAsClick(menu);
149
		this.keysAsClick(parentLi);
150
151
		// The click event listener for opening sub menus
152
		subLink.addEventListener('click', function(e) {
153
			// Reset all sublinks in this menu
154
			this.resetSubLinks(subLink);
155
156
			// If its not open, lets show it as selected
157
			if (!e.currentTarget.classList.contains('open'))
158
			{
159
				// Don't follow the menuLink (if any) when first opening the submenu
160
				e.preventDefault();
161
162
				e.currentTarget.setAttribute('aria-pressed', 'true');
163
				e.currentTarget.setAttribute('aria-expanded', 'true');
164
			}
165
166
			// Reset all the submenus in this menu
167
			this.resetSubMenus(subLink);
168
169
			// Grab the selected UL submenu
170
			let currentMenu = subLink.parentNode ? subLink.parentNode.querySelector('ul:first-of-type') : null;
171
172
			// Open its link and list
173
			parentLi.classList.add('open');
174
			e.currentTarget.classList.add('open');
175
176
			// Open the UL menu
177
			if (currentMenu)
178
			{
179
				currentMenu.classList.remove('un_selected');
180
				currentMenu.classList.add('selected');
181
				currentMenu.setAttribute('aria-hidden', 'false');
182
			}
183
		}.bind(this));
184
	}.bind(this));
185
};
186
187
/**
188
 * Reset the current level submenu(s) as closed
189
 *
190
 * @param {HTMLElement} subLink
191
 */
192
elkMenu.prototype.resetSubMenus = function(subLink) {
193
	if (!subLink)
194
	{
195
		return;
196
	}
197
198
	// Determine the UL scope of the current level
199
	let levelUl = subLink.closest ? subLink.closest('ul') : (subLink.parentNode && subLink.parentNode.parentNode ? subLink.parentNode.parentNode : null);
200
	if (!levelUl)
201
	{
202
		return;
203
	}
204
205
	let subMenus = levelUl.querySelectorAll('li > a + ul:first-of-type');
206
	subMenus.forEach(function(menu) {
207
		// Remove open from the LI and LI A for this menu
208
		let parent = menu.parentNode;
209
		parent.classList.remove('open');
210
		parent.querySelector('a').classList.remove('open');
211
212
		// Remove open and selected for this menu
213
		menu.classList.remove('open', 'selected');
214
		menu.classList.add('un_selected');
215
		menu.setAttribute('aria-hidden', 'true');
216
	});
217
};
218
219
/**
220
 * Resets any links that are not pointing at an open submenu
221
 *
222
 * @param {HTMLElement} subLink the .menulevel# link that has been clicked
223
 */
224
elkMenu.prototype.resetSubLinks = function(subLink) {
225
	if (!subLink)
226
	{
227
		return;
228
	}
229
230
	// The all closed menus at this level
231
	let levelUl = subLink.closest ? subLink.closest('ul') : (subLink.parentNode && subLink.parentNode.parentNode ? subLink.parentNode.parentNode : null);
232
	if (!levelUl)
233
	{
234
		return;
235
	}
236
237
	let subMenus = levelUl.querySelectorAll('li > a + ul:not(.open)');
238
	subMenus.forEach(function(menu) {
239
		// links to closed menus are no longer active
240
		let thisLink = menu.parentNode.querySelector('a');
241
		if (thisLink)
242
		{
243
			thisLink.setAttribute('aria-pressed', 'false');
244
			thisLink.setAttribute('aria-expanded', 'false');
245
		}
246
	});
247
};
248
249
/*
250
 * Reset all aria labels to initial closed state, remove all added
251
 * open and selected classes from this menu.
252
 */
253
elkMenu.prototype.resetMenu = function(menu) {
254
	this.SetItemAttribute(menu, '[aria-hidden="false"]', {'aria-hidden': 'true'});
255
	this.SetItemAttribute(menu, '[aria-expanded="true"]', {'aria-expanded': 'false'});
256
	this.SetItemAttribute(menu, '[aria-pressed="true"]', {'aria-pressed': 'false'});
257
258
	menu.querySelectorAll('.selected').forEach(function(item) {
259
		item.classList.remove('selected');
260
		item.classList.add('un_selected');
261
	});
262
263
	menu.querySelectorAll('.open').forEach(function(item) {
264
		item.classList.remove('open');
265
	});
266
};
267
268
/**
269
 * Helper function to set attributes
270
 *
271
 * @param {HTMLElement} menu
272
 * @param {string} selector used to target specific attribute of menu
273
 * @param {object} attrs
274
 */
275
elkMenu.prototype.SetItemAttribute = function(menu, selector, attrs) {
276
	if (selector === '')
277
	{
278
		Object.keys(attrs).forEach(key => menu.setAttribute(key, attrs[key]));
279
		return;
280
	}
281
282
	if (!menu)
283
	{
284
		return;
285
	}
286
287
	menu.querySelectorAll(selector).forEach(function(item) {
288
		Object.keys(attrs).forEach(key => item.setAttribute(key, attrs[key]));
289
	});
290
};
291
292
/**
293
 * Allow for proper aria keydown events and keyboard navigation
294
 *
295
 * @param {HTMLElement} el
296
 */
297
elkMenu.prototype.keysAsClick = function(el) {
298
	if (!el || !el.addEventListener)
299
	{
300
		return;
301
	}
302
303
	el.addEventListener('keydown', function(event) {
304
		this.keysCallback(event, el);
305
	}.bind(this), true);
306
};
307
308
/**
309
 * Callback for keyAsClick
310
 *
311
 * @param {KeyboardEvent} keyboardEvent
312
 * @param {HTMLElement} el
313
 */
314
elkMenu.prototype.keysCallback = function(keyboardEvent, el) {
0 ignored issues
show
Unused Code introduced by
The parameter el is not used and could be removed.

This check looks for parameters in functions that are not used in the function body and are not followed by other parameters which are used inside the function.

Loading history...
315
	// THe keys we know how to respond to
316
	let keys = [' ', 'Enter', 'ArrowUp', 'ArrowLeft', 'ArrowDown', 'ArrowRight', 'Home', 'End', 'Escape', 'Spacebar'];
317
318
	if (keys.includes(keyboardEvent.key))
319
	{
320
		// What menu and links are we "in"
321
		let menu = keyboardEvent.target.closest ? keyboardEvent.target.closest('ul') : null;
322
		if (!menu)
323
		{
324
			return;
325
		}
326
327
		let menuLinks = Array.prototype.slice.call(menu.querySelectorAll('a')),
328
			currentIndex = menuLinks.indexOf(document.activeElement);
329
330
		// Don't follow the links, don't bubble the event
331
		keyboardEvent.stopPropagation();
332
		keyboardEvent.preventDefault();
333
		switch (keyboardEvent.key)
334
		{
335
			case 'Escape':
336
				this.resetSubMenus(menu);
337
				break;
338
			case ' ':
339
			case 'Spacebar':
340
			case 'Enter':
341
				if (currentIndex > -1 && menuLinks[currentIndex])
342
				{
343
					menuLinks[currentIndex].click();
344
				}
345
				break;
346
			case 'ArrowUp':
347
			case 'ArrowLeft':
348
				if (currentIndex > -1)
349
				{
350
					let prevIndex = Math.max(0, currentIndex - 1);
351
					menuLinks[prevIndex].focus();
352
				}
353
				break;
354
			case 'ArrowDown':
355
			case 'ArrowRight':
356
				if (currentIndex > -1)
357
				{
358
					let nextIndex = Math.min(menuLinks.length - 1, currentIndex + 1);
359
					menuLinks[nextIndex].focus();
360
				}
361
				break;
362
			case 'Home':
363
				if (menuLinks.length)
364
				{
365
					menuLinks[0].focus();
366
				}
367
				break;
368
			case 'End':
369
				if (menuLinks.length)
370
				{
371
					menuLinks[menuLinks.length - 1].focus();
372
				}
373
				break;
374
		}
375
	}
376
};
377