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 DomTraverse from '../../Dom/Traverse';
11 import DomUtil from '../../Dom/Util';
12 import * as Environment from '../../Environment';
13 import * as EventHandler from '../../Event/Handler';
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>();
22 constructor(container) {
23 this.container = container;
27 * Validates the properties and DOM structure of this container.
30 * <div class="tabMenuContainer">
33 * <li data-name="foo"><a>bar</a></li>
37 * <div id="foo">baz</div>
41 if (!this.container.classList.contains('tabMenuContainer')) {
45 const nav = DomTraverse.childByTag(this.container, 'NAV') as HTMLElement;
51 const tabs = nav.querySelectorAll('li');
52 if (tabs.length === 0) {
56 DomTraverse.childrenByTag(this.container, 'DIV').forEach((container: HTMLElement) => {
57 let name = container.dataset.name;
59 name = DomUtil.identify(container);
60 container.dataset.name = name;
63 this.containers.set(name, container);
66 const containerId = this.container.id;
68 const name = this._getTabName(tab);
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.");
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.");
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 + "').");
89 this.tabs.set(name, tab);
92 if (!this.tabs.size) {
93 throw new Error("Expected at least one tab (tab menu id: '" + containerId + "').");
97 this.container.dataset.isLegacy = 'true';
99 this.tabs.forEach(function (tab, name) {
100 tab.setAttribute('aria-controls', name);
108 * Initializes this tab menu.
110 init(oldTabs?: Map<string, HTMLLIElement> | null): HTMLElement | null {
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));
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.
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') {
129 firstChild.addEventListener('touchstart', () => {
132 firstChild.addEventListener('touchmove', () => {
135 firstChild.addEventListener('touchend', (event) => {
139 // This will block the regular click event from firing.
140 event.preventDefault();
142 // Invoke the click callback manually.
143 this._onClick(event);
150 let returnValue: HTMLElement | null = null;
152 const hash = TabMenuSimple.getIdentifierFromHash();
153 let selectTab: HTMLLIElement | undefined = undefined;
155 selectTab = this.tabs.get(hash);
157 // check for parent tab menu
159 const item = this.container.parentNode as HTMLElement;
160 if (item.classList.contains('tabMenuContainer')) {
167 let preselect: unknown = this.container.dataset.preselect || this.container.dataset.active;
168 if (preselect === "true" || !preselect) {
172 if (preselect === true) {
173 this.tabs.forEach(function (tab) {
174 if (!selectTab && !DomUtil.isHidden(tab) && (!tab.previousElementSibling || DomUtil.isHidden(tab.previousElementSibling as HTMLElement))) {
178 } else if (typeof preselect === 'string' && preselect !== "false") {
179 selectTab = this.tabs.get(preselect);
184 this.containers.forEach(container => {
185 container.classList.add('hidden');
188 this.select(null, selectTab, true);
191 const store = this.container.dataset.store;
193 const input = document.createElement('input');
194 input.type = 'hidden';
196 input.value = this.getActiveTab().dataset.name || '';
198 this.container.appendChild(input);
210 * @param {?(string|int)} name tab name or sequence no
211 * @param {Element=} tab tab element
212 * @param {boolean=} disableEvent suppress event handling
214 select(name: number | string | null, tab?: HTMLLIElement, disableEvent?: boolean): void {
215 name = (name) ? name.toString() : '';
216 tab = tab || this.tabs.get(name);
219 // check if name is an integer
220 if (~~name === +name) {
224 this.tabs.forEach(item => {
234 throw new Error("Expected a valid tab name, '" + name + "' given (tab menu id: '" + this.container.id + "').");
238 name = (name || tab.dataset.name || '') as string;
241 const oldTab = this.getActiveTab();
242 let oldContent: HTMLElement | null = null;
244 const oldTabName = oldTab.dataset.name;
245 if (oldTabName === name) {
251 EventHandler.fire('com.woltlab.wcf.simpleTabMenu_' + this.container.id, 'beforeSelect', {
257 oldTab.classList.remove('active');
258 oldContent = this.containers.get(oldTab.dataset.name || '')!;
259 oldContent.classList.remove('active');
260 oldContent.classList.add('hidden');
263 oldTab.classList.remove('ui-state-active');
264 oldContent.classList.remove('ui-state-active');
268 tab.classList.add('active');
269 const newContent = this.containers.get(name)!;
270 newContent.classList.add('active');
271 newContent.classList.remove('hidden');
274 tab.classList.add('ui-state-active');
275 newContent.classList.add('ui-state-active');
279 this.store.value = name;
283 EventHandler.fire('com.woltlab.wcf.simpleTabMenu_' + this.container.id, 'select', {
287 previousName: oldTab ? oldTab.dataset.name : null,
290 const jQuery = (this.isLegacy && typeof window.jQuery === 'function') ? window.jQuery : null;
292 // simulate jQuery UI Tabs event
293 jQuery(this.container).trigger('wcftabsbeforeactivate', {
295 oldTab: jQuery(oldTab),
296 newPanel: jQuery(newContent),
297 oldPanel: jQuery(oldContent),
301 let location = window.location.href.replace(/#+[^#]*$/, '');
302 if (TabMenuSimple.getIdentifierFromHash() === name) {
303 location += window.location.hash;
305 location += '#' + name;
309 window.history.replaceState(
318 require(['WoltLabSuite/Core/Ui/TabMenu'], function (UiTabMenu) {
319 //noinspection JSUnresolvedFunction
320 UiTabMenu.scrollToTab(tab);
326 * Selects the first visible tab of the tab menu and return `true`. If there is no
327 * visible tab, `false` is returned.
329 * The visibility of a tab is determined by calling `elIsHidden` with the tab menu
330 * item as the parameter.
332 selectFirstVisible(): boolean {
333 let selectTab: HTMLLIElement | null = null;
334 this.tabs.forEach(tab => {
335 if (!selectTab && !DomUtil.isHidden(tab)) {
341 this.select(null, selectTab, false);
344 return selectTab !== null;
348 * Rebuilds all tabs, must be invoked after adding or removing of tabs.
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.
354 const oldTabs = new Map<string, HTMLLIElement>(this.tabs);
361 * Returns true if this tab menu has a tab with provided name.
363 hasTab(name: string): boolean {
364 return this.tabs.has(name);
368 * Handles clicks on a tab.
370 _onClick(event: MouseEvent | TouchEvent): void {
371 event.preventDefault();
373 const target = event.currentTarget as HTMLElement;
374 this.select(null, target.parentNode as HTMLLIElement);
378 * Returns the tab name.
380 _getTabName(tab: HTMLLIElement): string | null {
381 let name = tab.dataset.name || null;
383 // handle legacy tab menus
385 if (tab.childElementCount === 1 && tab.children[0].nodeName === 'A') {
386 const link = tab.children[0] as HTMLAnchorElement;
387 if (link.href.match(/#([^#]+)$/)) {
390 if (document.getElementById(name) === null) {
393 this.isLegacy = true;
394 tab.dataset.name = name;
404 * Returns the currently active tab.
406 getActiveTab(): HTMLLIElement {
407 return document.querySelector('#' + this.container.id + ' > nav > ul > li.active') as HTMLLIElement;
411 * Returns the list of registered content containers.
413 getContainers(): Map<string, HTMLElement> {
414 return this.containers;
418 * Returns the list of registered tabs.
420 getTabs(): Map<string, HTMLLIElement> {
424 static getIdentifierFromHash() {
425 if (window.location.hash.match(/^#+([^\/]+)+(?:\/.+)?/)) {
433 export = TabMenuSimple;