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

Complexity

Total Complexity 35
Complexity/F 1.46

Size

Lines of Code 271
Function Count 24

Duplication

Duplicated Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 132
dl 0
loc 271
rs 9.6
c 0
b 0
f 0
wmc 35
mnd 11
bc 11
fnc 24
bpm 0.4583
cpm 1.4583
noi 1
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 dev
7
 */
8
9
/**
10
 * Menu functions to allow touchscreen / keyboard interaction in place of hover/mouse
11
 *
12
 * @param {string} menuID the selector of the top-level UL in the menu structure
13
 */
14
function elkMenu (menuID)
15
{
16
	this.menu = document.querySelector(menuID);
17
	if (this.menu !== null)
18
	{
19
		this.initMenu();
20
	}
21
}
22
23
/**
24
 * Setup the menu to work with click / keyboard events instead of :hover
25
 */
26
elkMenu.prototype.initMenu = function() {
27
	// Setup enter/spacebar keys to trigger a click on the "Skip to main content" link
28
	if (this.menu.id === 'main_menu')
29
	{
30
		this.keysAsClick(document.getElementById('skipnav'));
31
	}
32
33
	// Removing this class prevents the standard hover effect, assuming the CSS is set up correctly
34
	this.menu.classList.remove('no_js');
35
	this.menu.parentElement.classList.remove('no_js');
36
37
	// The subMenus (ul.menulevel#)
38
	let subMenu = this.menu.querySelectorAll('a + ul');
39
40
	// Initial aria-hidden = true for all subMenus
41
	subMenu.forEach(function(item) {
42
		item.setAttribute('aria-hidden', 'true');
43
	});
44
45
	// Document level events to close dropdowns on page click or ESC
46
	this.docKeydown();
47
	this.docClick();
48
49
	// Setup the subMenus (menulevel2, menulevel3) to open when clicked
50
	this.submenuReveal(subMenu);
51
};
52
53
/**
54
 * CLose menu on click outside of its structure
55
 */
56
elkMenu.prototype.docClick = function() {
57
	document.body.addEventListener('click', function(e) {
58
		// Clicked outside of this.menu
59
		if (!this.menu.contains(e.target))
60
		{
61
			this.resetMenu(this.menu);
62
		}
63
64
		// Clicked inside of the this.menu hierarchy, but not on a link
65
		let menuClick = e.target.closest('#' + this.menu.getAttribute('id'));
66
		if ((menuClick && e.target.tagName.toLowerCase() === 'ul'))
67
		{
68
			this.resetMenu(this.menu);
69
		}
70
	}.bind(this));
71
};
72
73
/**
74
 * Pressed the escape key, close any open dropdowns.  This will fire
75
 * if a menu/submenu does not capture the keyboard event first, as if they
76
 * are not open or lost focus.
77
 */
78
elkMenu.prototype.docKeydown = function() {
79
	document.body.addEventListener('keydown', function(e) {
80
		e = e || window.e;
81
		if (e.key === 'Escape')
82
		{
83
			this.resetMenu(this.menu);
84
		}
85
	}.bind(this));
86
};
87
88
/**
89
 * Sets class and aria values for open or closed submenus.  Prevents default
90
 * action on links that are both disclose and navigation by revealing on the
91
 * first click and then following on the second.
92
 *
93
 * @param {NodeListOf} subMenu
94
 */
95
elkMenu.prototype.submenuReveal = function(subMenu) {
96
	// All the subMenus menulevel2, menulevel3
97
	Array.prototype.forEach.call(subMenu, function(menu) {
98
		// The menu items container LI and link LI > A
99
		let parentLi = menu.parentNode,
100
			subLink = parentLi.querySelector('a');
101
102
		// Initial aria and role for each submenu trigger
103
		this.SetItemAttribute(subLink, '', {
104
			'role': 'button',
105
			'aria-pressed': 'false',
106
			'aria-expanded': 'false'
107
		});
108
109
		// Setup keyboard navigation
110
		this.keysAsClick(menu);
111
		this.keysAsClick(parentLi);
112
113
		// The click event listener for opening sub menus
114
		subLink.addEventListener('click', function(e) {
115
			// Reset all sublinks in this menu
116
			this.resetSubLinks(subLink);
117
118
			// If its not open, lets show it as selected
119
			if (!e.currentTarget.classList.contains('open'))
120
			{
121
				// Don't follow the menuLink (if any) when first opening the submenu
122
				e.preventDefault();
123
124
				e.currentTarget.setAttribute('aria-pressed', 'true');
125
				e.currentTarget.setAttribute('aria-expanded', 'true');
126
			}
127
128
			// Reset all the submenus in this menu
129
			this.resetSubMenus(subLink);
130
131
			// Grab the selected UL submenu
132
			let currentMenu = subLink.parentNode.querySelector('ul:first-of-type');
133
134
			// Open its link and list
135
			parentLi.classList.add('open');
136
			e.currentTarget.classList.add('open');
137
138
			// Open the UL menu
139
			currentMenu.classList.remove('un_selected');
140
			currentMenu.classList.add('selected');
141
			currentMenu.setAttribute('aria-hidden', 'false');
142
		}.bind(this));
143
	}.bind(this));
144
};
145
146
/**
147
 * Reset the current level submenu(s) as closed
148
 *
149
 * @param {HTMLElement} subLink
150
 */
151
elkMenu.prototype.resetSubMenus = function(subLink) {
152
	let subMenus = subLink.parentNode.parentNode.querySelectorAll('li > a + ul:first-of-type');
153
	subMenus.forEach(function(menu) {
154
		// Remove open from the LI and LI A for this menu
155
		let parent = menu.parentNode;
156
		parent.classList.remove('open');
157
		parent.querySelector('a').classList.remove('open');
158
159
		// Remove open and selected for this menu
160
		menu.classList.remove('open', 'selected');
161
		menu.classList.add('un_selected');
162
		menu.setAttribute('aria-hidden', 'true');
163
	});
164
};
165
166
/**
167
 * Resets any links that are not pointing at an open submenu
168
 *
169
 * @param {HTMLElement} subLink the .menulevel# link that has been clicked
170
 */
171
elkMenu.prototype.resetSubLinks = function(subLink) {
172
	// The all closed menus
173
	let subMenus = subLink.parentNode.parentNode.querySelectorAll('li > a + ul:not(.open)');
174
	subMenus.forEach(function(menu) {
175
		// links to closed menus are no longer active
176
		let thisLink = menu.parentNode.querySelector('a');
177
		thisLink.setAttribute('aria-pressed', 'false');
178
		thisLink.setAttribute('aria-expanded', 'false');
179
	});
180
};
181
182
/*
183
 * Reset all aria labels to initial closed state, remove all added
184
 * open and selected classes from this menu.
185
 */
186
elkMenu.prototype.resetMenu = function(menu) {
187
	this.SetItemAttribute(menu, '[aria-hidden="false"]', {'aria-hidden': 'true'});
188
	this.SetItemAttribute(menu, '[aria-expanded="true"]', {'aria-expanded': 'false'});
189
	this.SetItemAttribute(menu, '[aria-pressed="true"]', {'aria-pressed': 'false'});
190
191
	menu.querySelectorAll('.selected').forEach(function(item) {
192
		item.classList.remove('selected');
193
		item.classList.add('un_selected');
194
	});
195
196
	menu.querySelectorAll('.open').forEach(function(item) {
197
		item.classList.remove('open');
198
	});
199
};
200
201
/**
202
 * Helper function to set attributes
203
 *
204
 * @param {HTMLElement} menu
205
 * @param {string} selector used to target specific attribute of menu
206
 * @param {object} attrs
207
 */
208
elkMenu.prototype.SetItemAttribute = function(menu, selector, attrs) {
209
	if (selector === '')
210
	{
211
		Object.keys(attrs).forEach(key => menu.setAttribute(key, attrs[key]));
212
		return;
213
	}
214
215
	menu.querySelectorAll(selector).forEach(function(item) {
216
		Object.keys(attrs).forEach(key => item.setAttribute(key, attrs[key]));
217
	});
218
};
219
220
/**
221
 * Allow for proper aria keydown events and keyboard navigation
222
 *
223
 * @param {HTMLElement} el
224
 */
225
elkMenu.prototype.keysAsClick = function(el) {
226
	el.addEventListener('keydown', function(event) {
227
		this.keysCallback(event, el);
228
	}.bind(this), true);
229
};
230
231
/**
232
 * Callback for keyAsClick
233
 *
234
 * @param {KeyboardEvent} keyboardEvent
235
 * @param {HTMLElement} el
236
 */
237
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...
238
	// THe keys we know how to respond to
239
	let keys = [' ', 'Enter', 'ArrowUp', 'ArrowLeft', 'ArrowDown', 'ArrowRight', 'Home', 'End', 'Escape'];
240
241
	if (keys.includes(keyboardEvent.key))
242
	{
243
		// What menu and links are we "in"
244
		let menu = keyboardEvent.target.closest('ul'),
245
			menuLinks = Array.prototype.slice.call(menu.querySelectorAll('a')),
246
			currentIndex = menuLinks.indexOf(document.activeElement);
247
248
		// Don't follow the links, don't bubble the event
249
		keyboardEvent.stopPropagation();
250
		keyboardEvent.preventDefault();
251
		switch (keyboardEvent.key)
252
		{
253
			case 'Escape':
254
				this.resetSubMenus(menu);
255
				break;
256
			case ' ':
257
			case 'Enter':
258
				menuLinks[currentIndex].click();
259
				break;
260
			case 'ArrowUp':
261
			case 'ArrowLeft':
262
				if (currentIndex > -1)
263
				{
264
					let prevIndex = Math.max(0, currentIndex - 1);
265
					menuLinks[prevIndex].focus();
266
				}
267
				break;
268
			case 'ArrowDown':
269
			case 'ArrowRight':
270
				if (currentIndex > -1)
271
				{
272
					let nextIndex = Math.min(menuLinks.length - 1, currentIndex + 1);
273
					menuLinks[nextIndex].focus();
274
				}
275
				break;
276
			case 'Home':
277
				menuLinks[1].focus();
278
				break;
279
			case 'End':
280
				menuLinks[menuLinks.length - 1].focus();
281
				break;
282
		}
283
	}
284
};
285