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