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 tab.children[0].addEventListener('click', this._onClick.bind(this));
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.
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') {
128 tab.children[0].addEventListener('touchstart', () => {
131 tab.children[0].addEventListener('touchmove', () => {
134 tab.children[0].addEventListener('touchend', (event: MouseEvent) => {
138 // This will block the regular click event from firing.
139 event.preventDefault();
141 // Invoke the click callback manually.
142 this._onClick(event);
149 let returnValue: HTMLElement | null = null;
151 const hash = TabMenuSimple.getIdentifierFromHash();
152 let selectTab: HTMLLIElement | undefined = undefined;
154 selectTab = this.tabs.get(hash);
156 // check for parent tab menu
158 const item = this.container.parentNode as HTMLElement;
159 if (item.classList.contains('tabMenuContainer')) {
166 let preselect: unknown = this.container.dataset.preselect || this.container.dataset.active;
167 if (preselect === "true" || !preselect) {
171 if (preselect === true) {
172 this.tabs.forEach(function (tab) {
173 if (!selectTab && !DomUtil.isHidden(tab) && (!tab.previousElementSibling || DomUtil.isHidden(tab.previousElementSibling as HTMLElement))) {
177 } else if (typeof preselect === 'string' && preselect !== "false") {
178 selectTab = this.tabs.get(preselect);
183 this.containers.forEach(container => {
184 container.classList.add('hidden');
187 this.select(null, selectTab, true);
190 const store = this.container.dataset.store;
192 const input = document.createElement('input');
193 input.type = 'hidden';
195 input.value = this.getActiveTab().dataset.name || '';
197 this.container.appendChild(input);
209 * @param {?(string|int)} name tab name or sequence no
210 * @param {Element=} tab tab element
211 * @param {boolean=} disableEvent suppress event handling
213 select(name: number | string | null, tab?: HTMLLIElement, disableEvent?: boolean): void {
214 name = (name) ? name.toString() : '';
215 tab = tab || this.tabs.get(name);
218 // check if name is an integer
219 if (~~name === +name) {
223 this.tabs.forEach(item => {
233 throw new Error("Expected a valid tab name, '" + name + "' given (tab menu id: '" + this.container.id + "').");
237 name = (name || tab.dataset.name || '') as string;
240 const oldTab = this.getActiveTab();
241 let oldContent: HTMLElement | null = null;
243 const oldTabName = oldTab.dataset.name;
244 if (oldTabName === name) {
250 EventHandler.fire('com.woltlab.wcf.simpleTabMenu_' + this.container.id, 'beforeSelect', {
256 oldTab.classList.remove('active');
257 oldContent = this.containers.get(oldTab.dataset.name || '')!;
258 oldContent.classList.remove('active');
259 oldContent.classList.add('hidden');
262 oldTab.classList.remove('ui-state-active');
263 oldContent.classList.remove('ui-state-active');
267 tab.classList.add('active');
268 const newContent = this.containers.get(name)!;
269 newContent.classList.add('active');
270 newContent.classList.remove('hidden');
273 tab.classList.add('ui-state-active');
274 newContent.classList.add('ui-state-active');
278 this.store.value = name;
282 EventHandler.fire('com.woltlab.wcf.simpleTabMenu_' + this.container.id, 'select', {
286 previousName: oldTab ? oldTab.dataset.name : null,
289 const jQuery = (this.isLegacy && typeof window.jQuery === 'function') ? window.jQuery : null;
291 // simulate jQuery UI Tabs event
292 jQuery(this.container).trigger('wcftabsbeforeactivate', {
294 oldTab: jQuery(oldTab),
295 newPanel: jQuery(newContent),
296 oldPanel: jQuery(oldContent),
300 let location = window.location.href.replace(/#+[^#]*$/, '');
301 if (TabMenuSimple.getIdentifierFromHash() === name) {
302 location += window.location.hash;
304 location += '#' + name;
308 window.history.replaceState(
317 require(['WoltLabSuite/Core/Ui/TabMenu'], function (UiTabMenu) {
318 //noinspection JSUnresolvedFunction
319 UiTabMenu.scrollToTab(tab);
325 * Selects the first visible tab of the tab menu and return `true`. If there is no
326 * visible tab, `false` is returned.
328 * The visibility of a tab is determined by calling `elIsHidden` with the tab menu
329 * item as the parameter.
331 selectFirstVisible(): boolean {
332 let selectTab: HTMLLIElement | null = null;
333 this.tabs.forEach(tab => {
334 if (!selectTab && !DomUtil.isHidden(tab)) {
340 this.select(null, selectTab, false);
343 return selectTab !== null;
347 * Rebuilds all tabs, must be invoked after adding or removing of tabs.
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.
353 const oldTabs = new Map<string, HTMLLIElement>(this.tabs);
360 * Returns true if this tab menu has a tab with provided name.
362 hasTab(name: string): boolean {
363 return this.tabs.has(name);
367 * Handles clicks on a tab.
369 _onClick(event: MouseEvent): void {
370 event.preventDefault();
372 const target = event.currentTarget as HTMLElement;
373 this.select(null, target.parentNode as HTMLLIElement);
377 * Returns the tab name.
379 _getTabName(tab: HTMLLIElement): string | null {
380 let name = tab.dataset.name || null;
382 // handle legacy tab menus
384 if (tab.childElementCount === 1 && tab.children[0].nodeName === 'A') {
385 const link = tab.children[0] as HTMLAnchorElement;
386 if (link.href.match(/#([^#]+)$/)) {
389 if (document.getElementById(name) === null) {
392 this.isLegacy = true;
393 tab.dataset.name = name;
403 * Returns the currently active tab.
405 getActiveTab(): HTMLLIElement {
406 return document.querySelector('#' + this.container.id + ' > nav > ul > li.active') as HTMLLIElement;
410 * Returns the list of registered content containers.
412 getContainers(): Map<string, HTMLElement> {
413 return this.containers;
417 * Returns the list of registered tabs.
419 getTabs(): Map<string, HTMLLIElement> {
423 static getIdentifierFromHash() {
424 if (window.location.hash.match(/^#+([^\/]+)+(?:\/.+)?/)) {
432 export = TabMenuSimple;