2 * Simple tab menu implementation with a straight-forward logic.
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
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";
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>();
23 constructor(container: HTMLElement) {
24 this.container = container;
28 * Validates the properties and DOM structure of this container.
31 * <div class="tabMenuContainer">
34 * <li data-name="foo"><a>bar</a></li>
38 * <div id="foo">baz</div>
42 if (!this.container.classList.contains("tabMenuContainer")) {
46 const nav = DomTraverse.childByTag(this.container, "NAV") as HTMLElement;
52 const tabs = nav.querySelectorAll("li");
53 if (tabs.length === 0) {
57 DomTraverse.childrenByTag(this.container, "DIV").forEach((container: HTMLElement) => {
58 let name = container.dataset.name;
60 name = DomUtil.identify(container);
61 container.dataset.name = name;
64 this.containers.set(name, container);
67 const containerId = this.container.id;
68 tabs.forEach((tab) => {
69 const name = this._getTabName(tab);
74 if (this.tabs.has(name)) {
76 "Tab names must be unique, li[data-name='" +
78 "'] (tab menu id: '" +
80 "') exists more than once.",
84 const container = this.containers.get(name);
85 if (container === undefined) {
87 "Expected content element for li[data-name='" + name + "'] (tab menu id: '" + containerId + "').",
89 } else if (container.parentNode !== this.container) {
91 "Expected content element '" + name + "' (tab menu id: '" + containerId + "') to be a direct children.",
95 // check if tab holds exactly one children which is an anchor element
96 if (tab.childElementCount !== 1 || tab.children[0].nodeName !== "A") {
98 "Expected exactly one <a> as children for li[data-name='" + name + "'] (tab menu id: '" + containerId + "').",
102 this.tabs.set(name, tab);
105 if (!this.tabs.size) {
106 throw new Error("Expected at least one tab (tab menu id: '" + containerId + "').");
110 this.container.dataset.isLegacy = "true";
112 this.tabs.forEach(function (tab, name) {
113 tab.setAttribute("aria-controls", name);
121 * Initializes this tab menu.
123 init(oldTabs?: Map<string, HTMLLIElement> | null): HTMLElement | null {
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));
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.
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") {
142 firstChild.addEventListener("touchstart", () => {
145 firstChild.addEventListener("touchmove", () => {
148 firstChild.addEventListener("touchend", (event) => {
152 // This will block the regular click event from firing.
153 event.preventDefault();
155 // Invoke the click callback manually.
156 this._onClick(event);
163 let returnValue: HTMLElement | null = null;
165 const hash = TabMenuSimple.getIdentifierFromHash();
166 let selectTab: HTMLLIElement | undefined = undefined;
168 selectTab = this.tabs.get(hash);
170 // check for parent tab menu
172 const item = this.container.parentNode as HTMLElement;
173 if (item.classList.contains("tabMenuContainer")) {
180 let preselect: unknown = this.container.dataset.preselect || this.container.dataset.active;
181 if (preselect === "true" || !preselect) {
185 if (preselect === true) {
186 this.tabs.forEach(function (tab) {
189 !DomUtil.isHidden(tab) &&
190 (!tab.previousElementSibling || DomUtil.isHidden(tab.previousElementSibling as HTMLElement))
195 } else if (typeof preselect === "string" && preselect !== "false") {
196 selectTab = this.tabs.get(preselect);
201 this.containers.forEach((container) => {
202 container.classList.add("hidden");
205 this.select(null, selectTab, true);
208 const store = this.container.dataset.store;
210 const input = document.createElement("input");
211 input.type = "hidden";
213 input.value = this.getActiveTab().dataset.name || "";
215 this.container.appendChild(input);
227 * @param {?(string|int)} name tab name or sequence no
228 * @param {Element=} tab tab element
229 * @param {boolean=} disableEvent suppress event handling
231 select(name: number | string | null, tab?: HTMLLIElement, disableEvent?: boolean): void {
232 name = name ? name.toString() : "";
233 tab = tab || this.tabs.get(name);
236 // check if name is an integer
237 if (~~name === +name) {
241 this.tabs.forEach((item) => {
251 throw new Error(`Expected a valid tab name, '${name}' given (tab menu id: '${this.container.id}').`);
255 name = (name || tab.dataset.name || "") as string;
258 const oldTab = this.getActiveTab();
259 let oldContent: HTMLElement | null = null;
261 const oldTabName = oldTab.dataset.name;
262 if (oldTabName === name) {
268 EventHandler.fire("com.woltlab.wcf.simpleTabMenu_" + this.container.id, "beforeSelect", {
274 oldTab.classList.remove("active");
275 oldContent = this.containers.get(oldTab.dataset.name || "")!;
276 oldContent.classList.remove("active");
277 oldContent.classList.add("hidden");
280 oldTab.classList.remove("ui-state-active");
281 oldContent.classList.remove("ui-state-active");
285 tab.classList.add("active");
286 const newContent = this.containers.get(name)!;
287 newContent.classList.add("active");
288 newContent.classList.remove("hidden");
291 tab.classList.add("ui-state-active");
292 newContent.classList.add("ui-state-active");
296 this.store.value = name;
300 EventHandler.fire("com.woltlab.wcf.simpleTabMenu_" + this.container.id, "select", {
304 previousName: oldTab ? oldTab.dataset.name : null,
307 const jQuery = this.isLegacy && typeof window.jQuery === "function" ? window.jQuery : null;
309 // simulate jQuery UI Tabs event
310 jQuery(this.container).trigger("wcftabsbeforeactivate", {
312 oldTab: jQuery(oldTab),
313 newPanel: jQuery(newContent),
314 oldPanel: jQuery(oldContent!),
318 let location = window.location.href.replace(/#+[^#]*$/, "");
319 if (TabMenuSimple.getIdentifierFromHash() === name) {
320 location += window.location.hash;
322 location += "#" + name;
326 window.history.replaceState(undefined, "", location);
329 void import("../TabMenu").then((UiTabMenu) => {
330 UiTabMenu.scrollToTab(tab!);
335 * Selects the first visible tab of the tab menu and return `true`. If there is no
336 * visible tab, `false` is returned.
338 * The visibility of a tab is determined by calling `elIsHidden` with the tab menu
339 * item as the parameter.
341 selectFirstVisible(): boolean {
342 let selectTab: HTMLLIElement | null = null;
343 this.tabs.forEach((tab) => {
344 if (!selectTab && !DomUtil.isHidden(tab)) {
350 this.select(null, selectTab, false);
353 return selectTab !== null;
357 * Rebuilds all tabs, must be invoked after adding or removing of tabs.
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.
363 const oldTabs = new Map<string, HTMLLIElement>(this.tabs);
370 * Returns true if this tab menu has a tab with provided name.
372 hasTab(name: string): boolean {
373 return this.tabs.has(name);
377 * Handles clicks on a tab.
379 _onClick(event: MouseEvent | TouchEvent): void {
380 event.preventDefault();
382 const target = event.currentTarget as HTMLElement;
383 this.select(null, target.parentNode as HTMLLIElement);
387 * Returns the tab name.
389 _getTabName(tab: HTMLLIElement): string | null {
390 let name = tab.dataset.name || null;
392 // handle legacy tab menus
394 if (tab.childElementCount === 1 && tab.children[0].nodeName === "A") {
395 const link = tab.children[0] as HTMLAnchorElement;
396 if (/#([^#]+)$/.exec(link.href)) {
399 if (document.getElementById(name) === null) {
402 this.isLegacy = true;
403 tab.dataset.name = name;
413 * Returns the currently active tab.
415 getActiveTab(): HTMLLIElement {
416 return document.querySelector("#" + this.container.id + " > nav > ul > li.active") as HTMLLIElement;
420 * Returns the list of registered content containers.
422 getContainers(): Map<string, HTMLElement> {
423 return this.containers;
427 * Returns the list of registered tabs.
429 getTabs(): Map<string, HTMLLIElement> {
433 static getIdentifierFromHash(): string {
434 if (/^#+([^/]+)+(?:\/.+)?/.exec(window.location.hash)) {
442 Core.enableLegacyInheritance(TabMenuSimple);
444 export = TabMenuSimple;