Merge branch '5.3'
[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 tab.children[0].addEventListener('click', this._onClick.bind(this));
115
116 // iOS 13 changed the behavior for click events after scrolling the menu. It prevents
117 // the synthetic mouse events like "click" from triggering for a short duration after
118 // a scrolling has occurred. If the user scrolls to the end of the list and immediately
119 // attempts to click the tab, nothing will happen. However, if the user waits for some
120 // time, the tap will trigger a "click" event again.
121 //
122 // A "click" event is basically the result of a touch without any (significant) finger
123 // movement indicated by a "touchmove" event. This changes allows the user to scroll
124 // both the menu and the page normally, but still benefit from snappy reactions when
125 // tapping a menu item.
126 if (Environment.platform() === 'ios') {
127 let isClick = false;
128 tab.children[0].addEventListener('touchstart', () => {
129 isClick = true;
130 });
131 tab.children[0].addEventListener('touchmove', () => {
132 isClick = false;
133 });
134 tab.children[0].addEventListener('touchend', (event: MouseEvent) => {
135 if (isClick) {
136 isClick = false;
137
138 // This will block the regular click event from firing.
139 event.preventDefault();
140
141 // Invoke the click callback manually.
142 this._onClick(event);
143 }
144 });
145 }
146 }
147 });
148
149 let returnValue: HTMLElement | null = null;
150 if (!oldTabs) {
151 const hash = TabMenuSimple.getIdentifierFromHash();
152 let selectTab: HTMLLIElement | undefined = undefined;
153 if (hash !== '') {
154 selectTab = this.tabs.get(hash);
155
156 // check for parent tab menu
157 if (selectTab) {
158 const item = this.container.parentNode as HTMLElement;
159 if (item.classList.contains('tabMenuContainer')) {
160 returnValue = item;
161 }
162 }
163 }
164
165 if (!selectTab) {
166 let preselect: unknown = this.container.dataset.preselect || this.container.dataset.active;
167 if (preselect === "true" || !preselect) {
168 preselect = true;
169 }
170
171 if (preselect === true) {
172 this.tabs.forEach(function (tab) {
173 if (!selectTab && !DomUtil.isHidden(tab) && (!tab.previousElementSibling || DomUtil.isHidden(tab.previousElementSibling as HTMLElement))) {
174 selectTab = tab;
175 }
176 });
177 } else if (typeof preselect === 'string' && preselect !== "false") {
178 selectTab = this.tabs.get(preselect);
179 }
180 }
181
182 if (selectTab) {
183 this.containers.forEach(container => {
184 container.classList.add('hidden');
185 });
186
187 this.select(null, selectTab, true);
188 }
189
190 const store = this.container.dataset.store;
191 if (store) {
192 const input = document.createElement('input');
193 input.type = 'hidden';
194 input.name = store;
195 input.value = this.getActiveTab().dataset.name || '';
196
197 this.container.appendChild(input);
198
199 this.store = input;
200 }
201 }
202
203 return returnValue;
204 }
205
206 /**
207 * Selects a tab.
208 *
209 * @param {?(string|int)} name tab name or sequence no
210 * @param {Element=} tab tab element
211 * @param {boolean=} disableEvent suppress event handling
212 */
213 select(name: number | string | null, tab?: HTMLLIElement, disableEvent?: boolean): void {
214 name = (name) ? name.toString() : '';
215 tab = tab || this.tabs.get(name);
216
217 if (!tab) {
218 // check if name is an integer
219 if (~~name === +name) {
220 name = ~~name;
221
222 let i = 0;
223 this.tabs.forEach(item => {
224 if (i === name) {
225 tab = item;
226 }
227
228 i++;
229 });
230 }
231
232 if (!tab) {
233 throw new Error("Expected a valid tab name, '" + name + "' given (tab menu id: '" + this.container.id + "').");
234 }
235 }
236
237 name = (name || tab.dataset.name || '') as string;
238
239 // unmark active tab
240 const oldTab = this.getActiveTab();
241 let oldContent: HTMLElement | null = null;
242 if (oldTab) {
243 const oldTabName = oldTab.dataset.name;
244 if (oldTabName === name) {
245 // same tab
246 return;
247 }
248
249 if (!disableEvent) {
250 EventHandler.fire('com.woltlab.wcf.simpleTabMenu_' + this.container.id, 'beforeSelect', {
251 tab: oldTab,
252 tabName: oldTabName,
253 });
254 }
255
256 oldTab.classList.remove('active');
257 oldContent = this.containers.get(oldTab.dataset.name || '')!;
258 oldContent.classList.remove('active');
259 oldContent.classList.add('hidden');
260
261 if (this.isLegacy) {
262 oldTab.classList.remove('ui-state-active');
263 oldContent.classList.remove('ui-state-active');
264 }
265 }
266
267 tab.classList.add('active');
268 const newContent = this.containers.get(name)!;
269 newContent.classList.add('active');
270 newContent.classList.remove('hidden');
271
272 if (this.isLegacy) {
273 tab.classList.add('ui-state-active');
274 newContent.classList.add('ui-state-active');
275 }
276
277 if (this.store) {
278 this.store.value = name;
279 }
280
281 if (!disableEvent) {
282 EventHandler.fire('com.woltlab.wcf.simpleTabMenu_' + this.container.id, 'select', {
283 active: tab,
284 activeName: name,
285 previous: oldTab,
286 previousName: oldTab ? oldTab.dataset.name : null,
287 });
288
289 const jQuery = (this.isLegacy && typeof window.jQuery === 'function') ? window.jQuery : null;
290 if (jQuery) {
291 // simulate jQuery UI Tabs event
292 jQuery(this.container).trigger('wcftabsbeforeactivate', {
293 newTab: jQuery(tab),
294 oldTab: jQuery(oldTab),
295 newPanel: jQuery(newContent),
296 oldPanel: jQuery(oldContent),
297 });
298 }
299
300 let location = window.location.href.replace(/#+[^#]*$/, '');
301 if (TabMenuSimple.getIdentifierFromHash() === name) {
302 location += window.location.hash;
303 } else {
304 location += '#' + name;
305 }
306
307 // update history
308 window.history.replaceState(
309 undefined,
310 '',
311 location,
312 );
313 }
314
315 // TODO
316 /*
317 require(['WoltLabSuite/Core/Ui/TabMenu'], function (UiTabMenu) {
318 //noinspection JSUnresolvedFunction
319 UiTabMenu.scrollToTab(tab);
320 });
321 */
322 }
323
324 /**
325 * Selects the first visible tab of the tab menu and return `true`. If there is no
326 * visible tab, `false` is returned.
327 *
328 * The visibility of a tab is determined by calling `elIsHidden` with the tab menu
329 * item as the parameter.
330 */
331 selectFirstVisible(): boolean {
332 let selectTab: HTMLLIElement | null = null;
333 this.tabs.forEach(tab => {
334 if (!selectTab && !DomUtil.isHidden(tab)) {
335 selectTab = tab;
336 }
337 });
338
339 if (selectTab) {
340 this.select(null, selectTab, false);
341 }
342
343 return selectTab !== null;
344 }
345
346 /**
347 * Rebuilds all tabs, must be invoked after adding or removing of tabs.
348 *
349 * Warning: Do not remove tabs if you plan to add these later again or at least clone the nodes
350 * to prevent issues with already bound event listeners. Consider hiding them via CSS.
351 */
352 rebuild(): void {
353 const oldTabs = new Map<string, HTMLLIElement>(this.tabs);
354
355 this.validate();
356 this.init(oldTabs);
357 }
358
359 /**
360 * Returns true if this tab menu has a tab with provided name.
361 */
362 hasTab(name: string): boolean {
363 return this.tabs.has(name);
364 }
365
366 /**
367 * Handles clicks on a tab.
368 */
369 _onClick(event: MouseEvent): void {
370 event.preventDefault();
371
372 const target = event.currentTarget as HTMLElement;
373 this.select(null, target.parentNode as HTMLLIElement);
374 }
375
376 /**
377 * Returns the tab name.
378 */
379 _getTabName(tab: HTMLLIElement): string | null {
380 let name = tab.dataset.name || null;
381
382 // handle legacy tab menus
383 if (!name) {
384 if (tab.childElementCount === 1 && tab.children[0].nodeName === 'A') {
385 const link = tab.children[0] as HTMLAnchorElement;
386 if (link.href.match(/#([^#]+)$/)) {
387 name = RegExp.$1;
388
389 if (document.getElementById(name) === null) {
390 name = null;
391 } else {
392 this.isLegacy = true;
393 tab.dataset.name = name;
394 }
395 }
396 }
397 }
398
399 return name;
400 }
401
402 /**
403 * Returns the currently active tab.
404 */
405 getActiveTab(): HTMLLIElement {
406 return document.querySelector('#' + this.container.id + ' > nav > ul > li.active') as HTMLLIElement;
407 }
408
409 /**
410 * Returns the list of registered content containers.
411 */
412 getContainers(): Map<string, HTMLElement> {
413 return this.containers;
414 }
415
416 /**
417 * Returns the list of registered tabs.
418 */
419 getTabs(): Map<string, HTMLLIElement> {
420 return this.tabs;
421 }
422
423 static getIdentifierFromHash() {
424 if (window.location.hash.match(/^#+([^\/]+)+(?:\/.+)?/)) {
425 return RegExp.$1;
426 }
427
428 return '';
429 };
430 }
431
432 export = TabMenuSimple;
433