Merge pull request #3666 from WoltLab/typescript-cleanup
[GitHub/WoltLab/WCF.git] / wcfsetup / install / files / ts / WoltLabSuite / Core / Ui / TabMenu / Simple.ts
1 /**
2 * Simple tab menu implementation with a straight-forward logic.
3 *
4 * @author Alexander Ebert
5 * @copyright 2001-2019 WoltLab GmbH
6 * @license GNU Lesser General Public License <http://opensource.org/licenses/lgpl-license.php>
7 * @module WoltLabSuite/Core/Ui/TabMenu/Simple
8 */
9
10 import * as DomTraverse from '../../Dom/Traverse';
11 import DomUtil from '../../Dom/Util';
12 import * as Environment from '../../Environment';
13 import * as EventHandler from '../../Event/Handler';
14
15 class TabMenuSimple {
16 private readonly container: HTMLElement;
17 private readonly containers = new Map<string, HTMLElement>();
18 private isLegacy = false;
19 private store: HTMLInputElement | null = null;
20 private readonly tabs = new Map<string, HTMLLIElement>();
21
22 constructor(container) {
23 this.container = container;
24 }
25
26 /**
27 * Validates the properties and DOM structure of this container.
28 *
29 * Expected DOM:
30 * <div class="tabMenuContainer">
31 * <nav>
32 * <ul>
33 * <li data-name="foo"><a>bar</a></li>
34 * </ul>
35 * </nav>
36 *
37 * <div id="foo">baz</div>
38 * </div>
39 */
40 validate(): boolean {
41 if (!this.container.classList.contains('tabMenuContainer')) {
42 return false;
43 }
44
45 const nav = DomTraverse.childByTag(this.container, 'NAV') as HTMLElement;
46 if (nav === null) {
47 return false;
48 }
49
50 // get children
51 const tabs = nav.querySelectorAll('li');
52 if (tabs.length === 0) {
53 return false;
54 }
55
56 DomTraverse.childrenByTag(this.container, 'DIV').forEach((container: HTMLElement) => {
57 let name = container.dataset.name;
58 if (!name) {
59 name = DomUtil.identify(container);
60 container.dataset.name = name;
61 }
62
63 this.containers.set(name, container);
64 });
65
66 const containerId = this.container.id;
67 tabs.forEach(tab => {
68 const name = this._getTabName(tab);
69 if (!name) {
70 return;
71 }
72
73 if (this.tabs.has(name)) {
74 throw new Error("Tab names must be unique, li[data-name='" + name + "'] (tab menu id: '" + containerId + "') exists more than once.");
75 }
76
77 const container = this.containers.get(name);
78 if (container === undefined) {
79 throw new Error("Expected content element for li[data-name='" + name + "'] (tab menu id: '" + containerId + "').");
80 } else if (container.parentNode !== this.container) {
81 throw new Error("Expected content element '" + name + "' (tab menu id: '" + containerId + "') to be a direct children.");
82 }
83
84 // check if tab holds exactly one children which is an anchor element
85 if (tab.childElementCount !== 1 || tab.children[0].nodeName !== 'A') {
86 throw new Error("Expected exactly one <a> as children for li[data-name='" + name + "'] (tab menu id: '" + containerId + "').");
87 }
88
89 this.tabs.set(name, tab);
90 });
91
92 if (!this.tabs.size) {
93 throw new Error("Expected at least one tab (tab menu id: '" + containerId + "').");
94 }
95
96 if (this.isLegacy) {
97 this.container.dataset.isLegacy = 'true';
98
99 this.tabs.forEach(function (tab, name) {
100 tab.setAttribute('aria-controls', name);
101 });
102 }
103
104 return true;
105 }
106
107 /**
108 * Initializes this tab menu.
109 */
110 init(oldTabs?: Map<string, HTMLLIElement> | null): HTMLElement | null {
111 // bind listeners
112 this.tabs.forEach(tab => {
113 if (!oldTabs || oldTabs.get(tab.dataset.name || '') !== tab) {
114 const firstChild = tab.children[0] as HTMLElement;
115 firstChild.addEventListener('click', (ev) => this._onClick(ev));
116
117 // iOS 13 changed the behavior for click events after scrolling the menu. It prevents
118 // the synthetic mouse events like "click" from triggering for a short duration after
119 // a scrolling has occurred. If the user scrolls to the end of the list and immediately
120 // attempts to click the tab, nothing will happen. However, if the user waits for some
121 // time, the tap will trigger a "click" event again.
122 //
123 // A "click" event is basically the result of a touch without any (significant) finger
124 // movement indicated by a "touchmove" event. This changes allows the user to scroll
125 // both the menu and the page normally, but still benefit from snappy reactions when
126 // tapping a menu item.
127 if (Environment.platform() === 'ios') {
128 let isClick = false;
129 firstChild.addEventListener('touchstart', () => {
130 isClick = true;
131 });
132 firstChild.addEventListener('touchmove', () => {
133 isClick = false;
134 });
135 firstChild.addEventListener('touchend', (event) => {
136 if (isClick) {
137 isClick = false;
138
139 // This will block the regular click event from firing.
140 event.preventDefault();
141
142 // Invoke the click callback manually.
143 this._onClick(event);
144 }
145 });
146 }
147 }
148 });
149
150 let returnValue: HTMLElement | null = null;
151 if (!oldTabs) {
152 const hash = TabMenuSimple.getIdentifierFromHash();
153 let selectTab: HTMLLIElement | undefined = undefined;
154 if (hash !== '') {
155 selectTab = this.tabs.get(hash);
156
157 // check for parent tab menu
158 if (selectTab) {
159 const item = this.container.parentNode as HTMLElement;
160 if (item.classList.contains('tabMenuContainer')) {
161 returnValue = item;
162 }
163 }
164 }
165
166 if (!selectTab) {
167 let preselect: unknown = this.container.dataset.preselect || this.container.dataset.active;
168 if (preselect === "true" || !preselect) {
169 preselect = true;
170 }
171
172 if (preselect === true) {
173 this.tabs.forEach(function (tab) {
174 if (!selectTab && !DomUtil.isHidden(tab) && (!tab.previousElementSibling || DomUtil.isHidden(tab.previousElementSibling as HTMLElement))) {
175 selectTab = tab;
176 }
177 });
178 } else if (typeof preselect === 'string' && preselect !== "false") {
179 selectTab = this.tabs.get(preselect);
180 }
181 }
182
183 if (selectTab) {
184 this.containers.forEach(container => {
185 container.classList.add('hidden');
186 });
187
188 this.select(null, selectTab, true);
189 }
190
191 const store = this.container.dataset.store;
192 if (store) {
193 const input = document.createElement('input');
194 input.type = 'hidden';
195 input.name = store;
196 input.value = this.getActiveTab().dataset.name || '';
197
198 this.container.appendChild(input);
199
200 this.store = input;
201 }
202 }
203
204 return returnValue;
205 }
206
207 /**
208 * Selects a tab.
209 *
210 * @param {?(string|int)} name tab name or sequence no
211 * @param {Element=} tab tab element
212 * @param {boolean=} disableEvent suppress event handling
213 */
214 select(name: number | string | null, tab?: HTMLLIElement, disableEvent?: boolean): void {
215 name = (name) ? name.toString() : '';
216 tab = tab || this.tabs.get(name);
217
218 if (!tab) {
219 // check if name is an integer
220 if (~~name === +name) {
221 name = ~~name;
222
223 let i = 0;
224 this.tabs.forEach(item => {
225 if (i === name) {
226 tab = item;
227 }
228
229 i++;
230 });
231 }
232
233 if (!tab) {
234 throw new Error("Expected a valid tab name, '" + name + "' given (tab menu id: '" + this.container.id + "').");
235 }
236 }
237
238 name = (name || tab.dataset.name || '') as string;
239
240 // unmark active tab
241 const oldTab = this.getActiveTab();
242 let oldContent: HTMLElement | null = null;
243 if (oldTab) {
244 const oldTabName = oldTab.dataset.name;
245 if (oldTabName === name) {
246 // same tab
247 return;
248 }
249
250 if (!disableEvent) {
251 EventHandler.fire('com.woltlab.wcf.simpleTabMenu_' + this.container.id, 'beforeSelect', {
252 tab: oldTab,
253 tabName: oldTabName,
254 });
255 }
256
257 oldTab.classList.remove('active');
258 oldContent = this.containers.get(oldTab.dataset.name || '')!;
259 oldContent.classList.remove('active');
260 oldContent.classList.add('hidden');
261
262 if (this.isLegacy) {
263 oldTab.classList.remove('ui-state-active');
264 oldContent.classList.remove('ui-state-active');
265 }
266 }
267
268 tab.classList.add('active');
269 const newContent = this.containers.get(name)!;
270 newContent.classList.add('active');
271 newContent.classList.remove('hidden');
272
273 if (this.isLegacy) {
274 tab.classList.add('ui-state-active');
275 newContent.classList.add('ui-state-active');
276 }
277
278 if (this.store) {
279 this.store.value = name;
280 }
281
282 if (!disableEvent) {
283 EventHandler.fire('com.woltlab.wcf.simpleTabMenu_' + this.container.id, 'select', {
284 active: tab,
285 activeName: name,
286 previous: oldTab,
287 previousName: oldTab ? oldTab.dataset.name : null,
288 });
289
290 const jQuery = (this.isLegacy && typeof window.jQuery === 'function') ? window.jQuery : null;
291 if (jQuery) {
292 // simulate jQuery UI Tabs event
293 jQuery(this.container).trigger('wcftabsbeforeactivate', {
294 newTab: jQuery(tab),
295 oldTab: jQuery(oldTab),
296 newPanel: jQuery(newContent),
297 oldPanel: jQuery(oldContent),
298 });
299 }
300
301 let location = window.location.href.replace(/#+[^#]*$/, '');
302 if (TabMenuSimple.getIdentifierFromHash() === name) {
303 location += window.location.hash;
304 } else {
305 location += '#' + name;
306 }
307
308 // update history
309 window.history.replaceState(
310 undefined,
311 '',
312 location,
313 );
314 }
315
316 // TODO
317 /*
318 require(['WoltLabSuite/Core/Ui/TabMenu'], function (UiTabMenu) {
319 //noinspection JSUnresolvedFunction
320 UiTabMenu.scrollToTab(tab);
321 });
322 */
323 }
324
325 /**
326 * Selects the first visible tab of the tab menu and return `true`. If there is no
327 * visible tab, `false` is returned.
328 *
329 * The visibility of a tab is determined by calling `elIsHidden` with the tab menu
330 * item as the parameter.
331 */
332 selectFirstVisible(): boolean {
333 let selectTab: HTMLLIElement | null = null;
334 this.tabs.forEach(tab => {
335 if (!selectTab && !DomUtil.isHidden(tab)) {
336 selectTab = tab;
337 }
338 });
339
340 if (selectTab) {
341 this.select(null, selectTab, false);
342 }
343
344 return selectTab !== null;
345 }
346
347 /**
348 * Rebuilds all tabs, must be invoked after adding or removing of tabs.
349 *
350 * Warning: Do not remove tabs if you plan to add these later again or at least clone the nodes
351 * to prevent issues with already bound event listeners. Consider hiding them via CSS.
352 */
353 rebuild(): void {
354 const oldTabs = new Map<string, HTMLLIElement>(this.tabs);
355
356 this.validate();
357 this.init(oldTabs);
358 }
359
360 /**
361 * Returns true if this tab menu has a tab with provided name.
362 */
363 hasTab(name: string): boolean {
364 return this.tabs.has(name);
365 }
366
367 /**
368 * Handles clicks on a tab.
369 */
370 _onClick(event: MouseEvent | TouchEvent): void {
371 event.preventDefault();
372
373 const target = event.currentTarget as HTMLElement;
374 this.select(null, target.parentNode as HTMLLIElement);
375 }
376
377 /**
378 * Returns the tab name.
379 */
380 _getTabName(tab: HTMLLIElement): string | null {
381 let name = tab.dataset.name || null;
382
383 // handle legacy tab menus
384 if (!name) {
385 if (tab.childElementCount === 1 && tab.children[0].nodeName === 'A') {
386 const link = tab.children[0] as HTMLAnchorElement;
387 if (link.href.match(/#([^#]+)$/)) {
388 name = RegExp.$1;
389
390 if (document.getElementById(name) === null) {
391 name = null;
392 } else {
393 this.isLegacy = true;
394 tab.dataset.name = name;
395 }
396 }
397 }
398 }
399
400 return name;
401 }
402
403 /**
404 * Returns the currently active tab.
405 */
406 getActiveTab(): HTMLLIElement {
407 return document.querySelector('#' + this.container.id + ' > nav > ul > li.active') as HTMLLIElement;
408 }
409
410 /**
411 * Returns the list of registered content containers.
412 */
413 getContainers(): Map<string, HTMLElement> {
414 return this.containers;
415 }
416
417 /**
418 * Returns the list of registered tabs.
419 */
420 getTabs(): Map<string, HTMLLIElement> {
421 return this.tabs;
422 }
423
424 static getIdentifierFromHash() {
425 if (window.location.hash.match(/^#+([^\/]+)+(?:\/.+)?/)) {
426 return RegExp.$1;
427 }
428
429 return '';
430 };
431 }
432
433 export = TabMenuSimple;
434