2024-05-25 04:33:22 +00:00
|
|
|
/**
|
|
|
|
* ext.tabberNeue
|
|
|
|
*
|
|
|
|
* NAMING THINGS ARE HARD :(
|
|
|
|
* TODO: Make class and function names more accurate?
|
|
|
|
* TODO: Split classes into different modules
|
|
|
|
*/
|
|
|
|
const config = require( './config.json' );
|
2024-05-25 20:38:57 +00:00
|
|
|
const Hash = require( './Hash.js' );
|
|
|
|
const Transclude = require( './Transclude.js' );
|
|
|
|
const Util = require( './Util.js' );
|
2024-05-25 04:33:22 +00:00
|
|
|
|
2024-06-07 18:19:43 +00:00
|
|
|
let resizeObserver;
|
|
|
|
|
2024-05-25 04:33:22 +00:00
|
|
|
/**
|
2024-05-27 01:54:17 +00:00
|
|
|
* Class representing TabberAction functionality for handling tab events and animations.
|
2024-05-25 04:33:22 +00:00
|
|
|
*
|
|
|
|
* @class
|
|
|
|
*/
|
2024-05-27 01:54:17 +00:00
|
|
|
class TabberAction {
|
2024-05-25 04:33:22 +00:00
|
|
|
/**
|
|
|
|
* Determines if animations should be shown based on the user's preference.
|
|
|
|
*
|
|
|
|
* @return {boolean} - Returns true if animations should be shown, false otherwise.
|
|
|
|
*/
|
|
|
|
static shouldShowAnimation() {
|
2024-05-27 01:54:17 +00:00
|
|
|
return (
|
|
|
|
!window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ||
|
2024-06-20 18:44:20 +00:00
|
|
|
!config.enableAnimation
|
2024-05-27 01:54:17 +00:00
|
|
|
);
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Toggles the animation state based on the user's preference.
|
|
|
|
* If animations should be shown,
|
|
|
|
* adds the 'tabber-animations-ready' class to the document element.
|
|
|
|
*
|
|
|
|
* @param {boolean} enableAnimations - Flag indicating whether animations should be enabled.
|
|
|
|
*/
|
|
|
|
static toggleAnimation( enableAnimations ) {
|
2024-05-27 01:54:17 +00:00
|
|
|
if ( !TabberAction.shouldShowAnimation() ) {
|
2024-05-25 04:33:22 +00:00
|
|
|
return;
|
|
|
|
}
|
2024-05-25 19:27:47 +00:00
|
|
|
window.requestAnimationFrame( () => {
|
2024-05-27 01:54:17 +00:00
|
|
|
document.documentElement.classList.toggle(
|
|
|
|
'tabber-animations-ready',
|
|
|
|
enableAnimations
|
|
|
|
);
|
2024-05-25 19:27:47 +00:00
|
|
|
} );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Updates the header overflow based on the scroll position of the tab list.
|
|
|
|
* If the tab list is scrollable, it adds/removes classes to show/hide navigation buttons.
|
|
|
|
*
|
2024-06-07 18:19:43 +00:00
|
|
|
* @param {Element} tablist - The tablist element in the tabber
|
2024-05-25 04:33:22 +00:00
|
|
|
*/
|
2024-06-07 18:19:43 +00:00
|
|
|
static updateHeaderOverflow( tablist ) {
|
|
|
|
const header = tablist.closest( '.tabber__header' );
|
2024-05-25 04:33:22 +00:00
|
|
|
const { roundScrollLeft } = Util;
|
|
|
|
const tablistWidth = tablist.offsetWidth;
|
|
|
|
const tablistScrollWidth = tablist.scrollWidth;
|
2024-05-27 02:29:20 +00:00
|
|
|
const isScrollable = tablistScrollWidth > tablistWidth;
|
2024-05-25 04:33:22 +00:00
|
|
|
|
|
|
|
if ( !isScrollable ) {
|
|
|
|
window.requestAnimationFrame( () => {
|
|
|
|
header.classList.remove( 'tabber__header--next-visible' );
|
|
|
|
header.classList.remove( 'tabber__header--prev-visible' );
|
|
|
|
} );
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const scrollLeft = roundScrollLeft( tablist.scrollLeft );
|
|
|
|
const isAtStart = scrollLeft <= 0;
|
|
|
|
const isAtEnd = scrollLeft + tablistWidth >= tablistScrollWidth;
|
|
|
|
const isAtMiddle = !isAtStart && !isAtEnd;
|
|
|
|
|
|
|
|
window.requestAnimationFrame( () => {
|
2024-05-27 01:54:17 +00:00
|
|
|
header.classList.toggle(
|
|
|
|
'tabber__header--next-visible',
|
|
|
|
isAtStart || isAtMiddle
|
|
|
|
);
|
|
|
|
header.classList.toggle(
|
|
|
|
'tabber__header--prev-visible',
|
|
|
|
isAtEnd || isAtMiddle
|
|
|
|
);
|
2024-05-25 04:33:22 +00:00
|
|
|
} );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2024-05-27 01:54:17 +00:00
|
|
|
* Animate and update the indicator position and width based on the active tab.
|
2024-05-25 04:33:22 +00:00
|
|
|
*
|
2024-05-27 01:54:17 +00:00
|
|
|
* @param {Element} indicator - The indicator element (optional, defaults to the first '.tabber__indicator' found in the parent).
|
|
|
|
* @param {Element} activeTab - The currently active tab.
|
|
|
|
* @param {Element} tablist - The parent element containing the tabs.
|
2024-05-25 04:33:22 +00:00
|
|
|
*/
|
2024-05-27 01:54:17 +00:00
|
|
|
static animateIndicator( indicator, activeTab, tablist ) {
|
2024-05-27 02:29:20 +00:00
|
|
|
const tablistScrollLeft = Util.roundScrollLeft( tablist.scrollLeft );
|
|
|
|
const width = Util.getElementSize( activeTab, 'width' );
|
|
|
|
const transformValue = activeTab.offsetLeft - tablistScrollLeft;
|
|
|
|
|
2024-06-23 19:31:44 +00:00
|
|
|
indicator.classList.add( 'tabber__indicator--visible' );
|
|
|
|
tablist.classList.add( 'tabber__tabs--animate' );
|
|
|
|
indicator.style.width = width + 'px';
|
|
|
|
indicator.style.transform = `translateX(${ transformValue }px)`;
|
|
|
|
setTimeout( () => {
|
|
|
|
indicator.classList.remove( 'tabber__indicator--visible' );
|
|
|
|
tablist.classList.remove( 'tabber__tabs--animate' );
|
|
|
|
}, 250 );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Returns the tabpanel element based on the tab element
|
|
|
|
*
|
|
|
|
* @param {Element} tab - The tab element
|
|
|
|
* @return {Element} The tabpanel element.
|
|
|
|
*/
|
|
|
|
static getTabpanel( tab ) {
|
|
|
|
return document.getElementById( tab.getAttribute( 'aria-controls' ) );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
2024-05-27 01:54:17 +00:00
|
|
|
/**
|
|
|
|
* Sets the active tab panel in the tabber element.
|
|
|
|
* Loads the content of the active tab panel if it has a 'data-mw-tabber-load-url' attribute.
|
|
|
|
* Adjusts the height of the section containing the active tab panel based on its content height.
|
|
|
|
* Scrolls the section to make the active tab panel visible.
|
|
|
|
*
|
|
|
|
* @param {Element} activeTabpanel - The active tab panel element to be set.
|
2024-06-23 19:31:44 +00:00
|
|
|
* @param {Element|null} currentActiveTabpanel - The current active tab panel element
|
2024-05-27 01:54:17 +00:00
|
|
|
*/
|
2024-06-23 19:31:44 +00:00
|
|
|
static setActiveTabpanel( activeTabpanel, currentActiveTabpanel = null ) {
|
2024-05-25 04:33:22 +00:00
|
|
|
const section = activeTabpanel.closest( '.tabber__section' );
|
|
|
|
|
|
|
|
if ( activeTabpanel.dataset.mwTabberLoadUrl ) {
|
2024-05-25 20:38:57 +00:00
|
|
|
const transclude = new Transclude( activeTabpanel );
|
|
|
|
transclude.loadPage();
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
window.requestAnimationFrame( () => {
|
2024-05-27 01:54:17 +00:00
|
|
|
const activeTabpanelHeight = Util.getElementSize(
|
|
|
|
activeTabpanel,
|
|
|
|
'height'
|
|
|
|
);
|
2024-05-25 04:33:22 +00:00
|
|
|
section.style.height = activeTabpanelHeight + 'px';
|
|
|
|
// Scroll to tab
|
|
|
|
section.scrollLeft = activeTabpanel.offsetLeft;
|
|
|
|
} );
|
2024-06-23 19:31:44 +00:00
|
|
|
|
|
|
|
if ( currentActiveTabpanel ) {
|
|
|
|
resizeObserver.unobserve( currentActiveTabpanel );
|
|
|
|
}
|
|
|
|
resizeObserver.observe( activeTabpanel );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2024-05-27 01:54:17 +00:00
|
|
|
* Sets the active tab in the tabber element.
|
2024-06-20 18:44:20 +00:00
|
|
|
* Updates the attributes of tabs and tab panels to reflect the active state.
|
|
|
|
* Animates the indicator to the active tab and sets the active tab panel.
|
2024-05-25 04:33:22 +00:00
|
|
|
*
|
2024-06-20 18:44:20 +00:00
|
|
|
* @param {Element} activeTab - The tab element to set as active.
|
|
|
|
* @return {Promise} - A promise that resolves once the active tab is set.
|
2024-05-25 04:33:22 +00:00
|
|
|
*/
|
2024-05-27 01:54:17 +00:00
|
|
|
static setActiveTab( activeTab ) {
|
2024-06-20 18:44:20 +00:00
|
|
|
return new Promise( ( resolve ) => {
|
2024-06-23 19:31:44 +00:00
|
|
|
const activeTabpanel = TabberAction.getTabpanel( activeTab );
|
2024-06-20 18:44:20 +00:00
|
|
|
const tabberEl = activeTabpanel.closest( '.tabber' );
|
|
|
|
const indicator = tabberEl.querySelector(
|
|
|
|
':scope > .tabber__header > .tabber__indicator'
|
|
|
|
);
|
2024-05-25 04:33:22 +00:00
|
|
|
|
2024-06-23 19:31:44 +00:00
|
|
|
const currentActiveTab = tabberEl.querySelector( ':scope > .tabber__header > .tabber__tabs > .tabber__tab[aria-selected="true"]' );
|
|
|
|
let currentActiveTabpanel;
|
2024-06-20 18:44:20 +00:00
|
|
|
|
2024-06-23 19:31:44 +00:00
|
|
|
if ( currentActiveTab ) {
|
|
|
|
currentActiveTabpanel = TabberAction.getTabpanel( currentActiveTab );
|
|
|
|
}
|
2024-06-20 18:44:20 +00:00
|
|
|
|
2024-06-23 19:31:44 +00:00
|
|
|
window.requestAnimationFrame( () => {
|
|
|
|
if ( currentActiveTab ) {
|
2024-06-23 20:28:20 +00:00
|
|
|
const currentActiveTabAttributes = {
|
|
|
|
tabindex: -1,
|
|
|
|
'aria-selected': 'false'
|
|
|
|
};
|
|
|
|
Util.setAttributes( currentActiveTab, currentActiveTabAttributes );
|
2024-06-23 19:31:44 +00:00
|
|
|
|
|
|
|
if ( currentActiveTabpanel ) {
|
2024-06-23 20:28:20 +00:00
|
|
|
const currentActiveTabpanelAttributes = {
|
|
|
|
tabindex: -1,
|
|
|
|
'aria-hidden': 'true'
|
|
|
|
};
|
|
|
|
Util.setAttributes( currentActiveTabpanel, currentActiveTabpanelAttributes );
|
2024-06-23 19:31:44 +00:00
|
|
|
}
|
2024-06-20 18:44:20 +00:00
|
|
|
}
|
2024-05-25 04:33:22 +00:00
|
|
|
|
2024-06-23 20:28:20 +00:00
|
|
|
const activeTabAttributes = {
|
|
|
|
tabindex: 0,
|
|
|
|
'aria-selected': 'true'
|
|
|
|
};
|
|
|
|
|
|
|
|
const activeTabpanelAttributes = {
|
|
|
|
tabindex: 0,
|
|
|
|
'aria-hidden': 'false'
|
|
|
|
};
|
|
|
|
|
|
|
|
Util.setAttributes( activeTab, activeTabAttributes );
|
|
|
|
Util.setAttributes( activeTabpanel, activeTabpanelAttributes );
|
2024-06-23 19:31:44 +00:00
|
|
|
|
2024-06-20 18:44:20 +00:00
|
|
|
TabberAction.animateIndicator(
|
|
|
|
indicator,
|
|
|
|
activeTab,
|
|
|
|
activeTab.parentElement
|
|
|
|
);
|
2024-06-23 19:31:44 +00:00
|
|
|
TabberAction.setActiveTabpanel( activeTabpanel, currentActiveTabpanel );
|
2024-05-25 20:20:59 +00:00
|
|
|
} );
|
2024-06-23 19:31:44 +00:00
|
|
|
|
2024-06-20 18:44:20 +00:00
|
|
|
resolve();
|
2024-05-25 20:20:59 +00:00
|
|
|
} );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Scrolls the tab list by the specified offset.
|
|
|
|
*
|
|
|
|
* @param {number} offset - The amount to scroll the tab list by.
|
|
|
|
* @param {Element} tablist - The tab list element to scroll.
|
|
|
|
*/
|
|
|
|
static scrollTablist( offset, tablist ) {
|
|
|
|
const scrollLeft = Util.roundScrollLeft( tablist.scrollLeft ) + offset;
|
|
|
|
|
|
|
|
window.requestAnimationFrame( () => {
|
|
|
|
tablist.scrollLeft = Math.min(
|
|
|
|
Math.max( scrollLeft, 0 ),
|
|
|
|
tablist.scrollWidth - tablist.offsetWidth
|
|
|
|
);
|
|
|
|
} );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles the click event on a header button element.
|
|
|
|
* Calculates the scroll offset based on the button type ('prev' or 'next').
|
|
|
|
* Scrolls the tab list by the calculated offset using the 'scrollTablist' method
|
2024-05-27 01:54:17 +00:00
|
|
|
* of the TabberAction class.
|
2024-05-25 04:33:22 +00:00
|
|
|
*
|
|
|
|
* @param {Element} button - The header button element that was clicked.
|
|
|
|
* @param {string} type - The type of button clicked ('prev' or 'next').
|
|
|
|
*/
|
|
|
|
static handleHeaderButton( button, type ) {
|
2024-05-27 01:54:17 +00:00
|
|
|
const tablist = button
|
|
|
|
.closest( '.tabber__header' )
|
|
|
|
.querySelector( '.tabber__tabs' );
|
2024-05-27 02:29:20 +00:00
|
|
|
const tablistWidth = tablist.offsetWidth;
|
2024-06-20 18:44:20 +00:00
|
|
|
const scrollOffset =
|
|
|
|
type === 'prev' ? -tablistWidth / 2 : tablistWidth / 2;
|
2024-05-27 01:54:17 +00:00
|
|
|
TabberAction.scrollTablist( scrollOffset, tablist );
|
|
|
|
}
|
2024-06-07 18:19:43 +00:00
|
|
|
|
|
|
|
/**
|
2024-06-07 18:53:48 +00:00
|
|
|
* Handles the resize event for tabber elements.
|
|
|
|
* Updates the header overflow if the resized element is a tab list,
|
|
|
|
* or sets the active tab panel if the resized element is a tab panel.
|
2024-06-07 18:19:43 +00:00
|
|
|
*
|
|
|
|
* @param {ResizeObserverEntry[]} entries - An array of ResizeObserverEntry objects.
|
|
|
|
*/
|
2024-06-07 18:53:48 +00:00
|
|
|
static onResize( entries ) {
|
2024-06-07 18:19:43 +00:00
|
|
|
for ( const { target } of entries ) {
|
2024-06-07 18:53:48 +00:00
|
|
|
if ( target.classList.contains( 'tabber__tabs' ) ) {
|
|
|
|
TabberAction.updateHeaderOverflow( target );
|
|
|
|
} else if ( target.classList.contains( 'tabber__panel' ) ) {
|
|
|
|
TabberAction.setActiveTabpanel( target );
|
|
|
|
}
|
2024-06-07 18:19:43 +00:00
|
|
|
}
|
|
|
|
}
|
2024-05-27 01:54:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Represents a TabberEvent class that handles events related to tab navigation.
|
|
|
|
*
|
|
|
|
* @class TabberEvent
|
|
|
|
* @param {Element} tabber - The tabber element containing the tabs and header.
|
|
|
|
* @param {Element} tablist - The tab list element containing the tab elements.
|
|
|
|
*/
|
|
|
|
class TabberEvent {
|
|
|
|
constructor( tabber, tablist ) {
|
|
|
|
this.tabber = tabber;
|
|
|
|
this.tablist = tablist;
|
|
|
|
this.header = this.tablist.parentElement;
|
|
|
|
this.tabs = this.tablist.querySelectorAll( ':scope > .tabber__tab' );
|
|
|
|
this.activeTab = this.tablist.querySelector( '[aria-selected="true"]' );
|
2024-06-23 19:38:27 +00:00
|
|
|
this.activeTabpanel = TabberAction.getTabpanel( this.activeTab );
|
2024-06-20 18:44:20 +00:00
|
|
|
this.indicator = this.tabber.querySelector(
|
|
|
|
':scope > .tabber__header > .tabber__indicator'
|
|
|
|
);
|
2024-05-27 01:54:17 +00:00
|
|
|
this.tabFocus = 0;
|
2024-06-20 18:44:20 +00:00
|
|
|
this.debouncedUpdateHeaderOverflow = mw.util.debounce(
|
|
|
|
() => TabberAction.updateHeaderOverflow( this.tablist ),
|
|
|
|
100
|
|
|
|
);
|
2024-05-27 01:54:17 +00:00
|
|
|
this.handleTabFocusChange = this.handleTabFocusChange.bind( this );
|
|
|
|
this.onHeaderClick = this.onHeaderClick.bind( this );
|
|
|
|
this.onTablistScroll = this.onTablistScroll.bind( this );
|
|
|
|
this.onTablistKeydown = this.onTablistKeydown.bind( this );
|
2024-06-06 20:54:31 +00:00
|
|
|
}
|
|
|
|
|
2024-05-25 04:33:22 +00:00
|
|
|
/**
|
2024-05-27 01:54:17 +00:00
|
|
|
* Returns a debounced function that updates the header overflow.
|
|
|
|
*
|
|
|
|
* @return {Function} A debounced function that updates the header overflow.
|
|
|
|
*/
|
|
|
|
debounceUpdateHeaderOverflow() {
|
|
|
|
return this.debouncedUpdateHeaderOverflow;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2024-06-02 17:49:05 +00:00
|
|
|
* Handles changing the focus to the tab based on the key pressed.
|
2024-05-27 01:54:17 +00:00
|
|
|
*
|
2024-06-02 17:49:05 +00:00
|
|
|
* @param {string} key - The key pressed ('home' or 'end' or 'right' or 'left').
|
2024-05-27 01:54:17 +00:00
|
|
|
*/
|
2024-06-02 17:49:05 +00:00
|
|
|
handleTabFocusChange( key ) {
|
2024-05-27 01:54:17 +00:00
|
|
|
this.tabs[ this.tabFocus ].setAttribute( 'tabindex', '-1' );
|
2024-06-02 17:49:05 +00:00
|
|
|
if ( key === 'home' ) {
|
|
|
|
this.tabFocus = 0;
|
|
|
|
} else if ( key === 'end' ) {
|
|
|
|
this.tabFocus = this.tabs.length - 1;
|
|
|
|
} else if ( key === 'right' ) {
|
2024-05-27 01:54:17 +00:00
|
|
|
this.tabFocus = ( this.tabFocus + 1 ) % this.tabs.length;
|
2024-06-02 17:49:05 +00:00
|
|
|
} else if ( key === 'left' ) {
|
2024-06-20 18:44:20 +00:00
|
|
|
this.tabFocus =
|
|
|
|
( this.tabFocus - 1 + this.tabs.length ) % this.tabs.length;
|
2024-05-27 01:54:17 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
this.tabs[ this.tabFocus ].setAttribute( 'tabindex', '0' );
|
|
|
|
this.tabs[ this.tabFocus ].focus();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles the click event on the tabber header.
|
|
|
|
* If a tab is clicked, it sets the active tab, updates the URL hash without adding to browser history,
|
|
|
|
* and sets the active tab using TabberAction.setActiveTab method.
|
|
|
|
* If a previous or next button is clicked on a pointer device, it handles the header button accordingly.
|
2024-05-25 04:33:22 +00:00
|
|
|
*
|
|
|
|
* @param {Event} e - The click event object.
|
|
|
|
*/
|
2024-05-27 01:54:17 +00:00
|
|
|
onHeaderClick( e ) {
|
2024-05-25 04:33:22 +00:00
|
|
|
const tab = e.target.closest( '.tabber__tab' );
|
|
|
|
if ( tab ) {
|
|
|
|
// Prevent default anchor actions
|
|
|
|
e.preventDefault();
|
2024-05-27 01:54:17 +00:00
|
|
|
this.activeTab = tab;
|
2024-06-23 19:38:27 +00:00
|
|
|
this.activeTabpanel = TabberAction.getTabpanel( this.activeTab );
|
2024-05-25 04:33:22 +00:00
|
|
|
|
|
|
|
// Update the URL hash without adding to browser history
|
|
|
|
if ( config.updateLocationOnTabChange ) {
|
2024-05-27 01:54:17 +00:00
|
|
|
history.replaceState(
|
|
|
|
null,
|
|
|
|
'',
|
2024-06-20 18:44:20 +00:00
|
|
|
window.location.pathname +
|
|
|
|
window.location.search +
|
|
|
|
'#' +
|
|
|
|
this.activeTab.getAttribute( 'aria-controls' )
|
2024-05-27 01:54:17 +00:00
|
|
|
);
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
2024-05-27 01:54:17 +00:00
|
|
|
TabberAction.setActiveTab( this.activeTab );
|
2024-05-25 04:33:22 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const isPointerDevice = window.matchMedia( '(hover: hover)' ).matches;
|
|
|
|
if ( isPointerDevice ) {
|
|
|
|
const prevButton = e.target.closest( '.tabber__header__prev' );
|
|
|
|
if ( prevButton ) {
|
2024-05-27 01:54:17 +00:00
|
|
|
TabberAction.handleHeaderButton( prevButton, 'prev' );
|
2024-05-25 04:33:22 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
const nextButton = e.target.closest( '.tabber__header__next' );
|
|
|
|
if ( nextButton ) {
|
2024-05-27 01:54:17 +00:00
|
|
|
TabberAction.handleHeaderButton( nextButton, 'next' );
|
2024-05-25 04:33:22 +00:00
|
|
|
return;
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2024-05-27 01:54:17 +00:00
|
|
|
* Update the header overflow based on the scroll position of the tablist.
|
|
|
|
*/
|
|
|
|
onTablistScroll() {
|
|
|
|
this.debouncedUpdateHeaderOverflow();
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Handles the keydown event on the tablist element.
|
2024-06-02 17:49:05 +00:00
|
|
|
* If the key pressed is 'Home', it changes the focus to the first tab.
|
|
|
|
* If the key pressed is 'End', it changes the focus to the last tab.
|
2024-05-27 01:54:17 +00:00
|
|
|
* If the key pressed is 'ArrowRight', it changes the focus to the next tab.
|
|
|
|
* If the key pressed is 'ArrowLeft', it changes the focus to the previous tab.
|
2024-05-25 04:33:22 +00:00
|
|
|
*
|
2024-05-27 01:54:17 +00:00
|
|
|
* @param {Event} e - The keydown event object.
|
2024-05-25 04:33:22 +00:00
|
|
|
*/
|
2024-05-27 01:54:17 +00:00
|
|
|
onTablistKeydown( e ) {
|
2024-06-02 17:49:05 +00:00
|
|
|
if ( e.key === 'Home' ) {
|
|
|
|
e.preventDefault();
|
|
|
|
this.handleTabFocusChange( 'home' );
|
|
|
|
} else if ( e.key === 'End' ) {
|
|
|
|
e.preventDefault();
|
|
|
|
this.handleTabFocusChange( 'end' );
|
|
|
|
} else if ( e.key === 'ArrowRight' ) {
|
2024-05-27 01:54:17 +00:00
|
|
|
this.handleTabFocusChange( 'right' );
|
|
|
|
} else if ( e.key === 'ArrowLeft' ) {
|
|
|
|
this.handleTabFocusChange( 'left' );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2024-06-06 20:54:31 +00:00
|
|
|
* Adds listeners for header click, tablist scroll, tablist keydown, and activeTabpanel resize.
|
2024-05-25 04:33:22 +00:00
|
|
|
*/
|
2024-05-27 01:54:17 +00:00
|
|
|
resume() {
|
|
|
|
this.header.addEventListener( 'click', this.onHeaderClick );
|
|
|
|
this.tablist.addEventListener( 'scroll', this.onTablistScroll );
|
|
|
|
this.tablist.addEventListener( 'keydown', this.onTablistKeydown );
|
2024-06-07 18:19:43 +00:00
|
|
|
resizeObserver.observe( this.tablist );
|
2024-06-23 19:38:27 +00:00
|
|
|
resizeObserver.observe( this.activeTabpanel );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
2024-05-27 01:54:17 +00:00
|
|
|
|
|
|
|
/**
|
2024-06-06 20:54:31 +00:00
|
|
|
* Removes listeners for header click, tablist scroll, tablist keydown, and activeTabpanel resize.
|
2024-05-27 01:54:17 +00:00
|
|
|
*/
|
|
|
|
pause() {
|
|
|
|
this.header.removeEventListener( 'click', this.onHeaderClick );
|
|
|
|
this.tablist.removeEventListener( 'scroll', this.onTablistScroll );
|
|
|
|
this.tablist.removeEventListener( 'keydown', this.onTablistKeydown );
|
2024-06-07 18:19:43 +00:00
|
|
|
resizeObserver.unobserve( this.tablist );
|
2024-06-23 19:38:27 +00:00
|
|
|
resizeObserver.unobserve( this.activeTabpanel );
|
2024-05-27 02:29:20 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Initializes the TabberEvent instance by creating an IntersectionObserver to handle tabber visibility.
|
2024-06-06 20:54:31 +00:00
|
|
|
* When the tabber intersects with the viewport, it resumes all event listeners and observers.
|
|
|
|
* Otherwise, it pauses the event listeners and observers.
|
2024-05-27 02:29:20 +00:00
|
|
|
*/
|
|
|
|
init() {
|
|
|
|
// eslint-disable-next-line compat/compat
|
|
|
|
this.observer = new IntersectionObserver( ( entries ) => {
|
|
|
|
entries.forEach( ( entry ) => {
|
|
|
|
if ( entry.isIntersecting ) {
|
|
|
|
this.resume();
|
|
|
|
} else {
|
|
|
|
this.pause();
|
|
|
|
}
|
|
|
|
} );
|
|
|
|
} );
|
|
|
|
this.observer.observe( this.tabber );
|
2024-06-23 19:38:27 +00:00
|
|
|
// Need to unobserve active tabpanels first so that only the ones in viewport are observed
|
|
|
|
resizeObserver.unobserve( this.activeTabpanel );
|
2024-05-27 02:29:20 +00:00
|
|
|
this.resume();
|
2024-05-27 01:54:17 +00:00
|
|
|
}
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Class responsible for creating tabs, headers, and indicators for a tabber element.
|
|
|
|
*
|
|
|
|
* @class TabberBuilder
|
|
|
|
*/
|
|
|
|
class TabberBuilder {
|
|
|
|
constructor( tabber ) {
|
|
|
|
this.tabber = tabber;
|
|
|
|
this.header = this.tabber.querySelector( ':scope > .tabber__header' );
|
|
|
|
this.tablist = document.createElement( 'nav' );
|
|
|
|
this.indicator = document.createElement( 'div' );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets the attributes of a tab element.
|
|
|
|
*
|
|
|
|
* @param {Element} tab - The tab element to set attributes for.
|
|
|
|
* @param {string} tabId - The ID of the tab element.
|
|
|
|
*/
|
|
|
|
setTabAttributes( tab, tabId ) {
|
|
|
|
const tabAttributes = {
|
|
|
|
class: 'tabber__tab',
|
2024-06-23 20:28:20 +00:00
|
|
|
href: '#' + tabId,
|
|
|
|
id: 'tab-' + tabId,
|
2024-05-25 04:33:22 +00:00
|
|
|
role: 'tab',
|
2024-06-23 20:28:20 +00:00
|
|
|
tabindex: '-1',
|
2024-05-25 04:33:22 +00:00
|
|
|
'aria-selected': false,
|
2024-06-23 20:28:20 +00:00
|
|
|
'aria-controls': tabId
|
2024-05-25 04:33:22 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
Util.setAttributes( tab, tabAttributes );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a tab element with the given title attribute and tab ID.
|
|
|
|
*
|
|
|
|
* @param {string} titleAttr - The title attribute for the tab element.
|
|
|
|
* @param {string} tabId - The ID of the tab element.
|
|
|
|
* @return {Element} The created tab element.
|
|
|
|
*/
|
|
|
|
createTab( titleAttr, tabId ) {
|
|
|
|
const tab = document.createElement( 'a' );
|
|
|
|
|
|
|
|
if ( config.parseTabName ) {
|
|
|
|
tab.innerHTML = titleAttr;
|
|
|
|
} else {
|
|
|
|
tab.textContent = titleAttr;
|
|
|
|
}
|
|
|
|
|
|
|
|
this.setTabAttributes( tab, tabId );
|
|
|
|
|
|
|
|
return tab;
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Sets the attributes of a tab panel element.
|
|
|
|
*
|
|
|
|
* @param {Element} tabpanel - The tab panel element to set attributes for.
|
|
|
|
* @param {string} tabId - The ID of the tab panel element.
|
|
|
|
*/
|
|
|
|
setTabpanelAttributes( tabpanel, tabId ) {
|
|
|
|
const tabpanelAttributes = {
|
2024-06-23 20:28:20 +00:00
|
|
|
id: tabId,
|
2024-05-25 04:33:22 +00:00
|
|
|
role: 'tabpanel',
|
2024-06-23 20:28:20 +00:00
|
|
|
tabindex: '-1',
|
|
|
|
'aria-hidden': 'true',
|
|
|
|
'aria-labelledby': `tab-${ tabId }`
|
2024-05-25 04:33:22 +00:00
|
|
|
};
|
|
|
|
|
|
|
|
Util.setAttributes( tabpanel, tabpanelAttributes );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates a tab element based on the provided tab panel.
|
|
|
|
*
|
|
|
|
* @param {Element} tabpanel - The tab panel element to create a tab element for.
|
|
|
|
* @return {Element|false} The created tab element, or false if the title attribute is missing
|
|
|
|
* or malformed.
|
|
|
|
*/
|
|
|
|
createTabElement( tabpanel ) {
|
|
|
|
const titleAttr = tabpanel.dataset.mwTabberTitle;
|
|
|
|
|
|
|
|
if ( !titleAttr ) {
|
2024-05-27 01:54:17 +00:00
|
|
|
mw.log.error(
|
|
|
|
'[TabberNeue] Missing or malformed `data-mw-tabber-title` attribute'
|
|
|
|
);
|
2024-05-25 04:33:22 +00:00
|
|
|
return false;
|
|
|
|
}
|
|
|
|
|
|
|
|
let tabId;
|
|
|
|
if ( config.parseTabName ) {
|
|
|
|
tabId = Hash.build( Util.extractTextFromHtml( titleAttr ) );
|
|
|
|
} else {
|
|
|
|
tabId = Hash.build( titleAttr );
|
|
|
|
}
|
|
|
|
|
|
|
|
this.setTabpanelAttributes( tabpanel, tabId );
|
|
|
|
|
|
|
|
return this.createTab( titleAttr, tabId );
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2024-06-20 18:44:20 +00:00
|
|
|
* Creates tabs for the tabber.
|
|
|
|
*
|
|
|
|
* This method creates tab elements for each tab panel in the tabber.
|
|
|
|
* It appends the created tabs to the tablist element, adds necessary attributes,
|
|
|
|
* and sets the role attribute for accessibility.
|
2024-05-25 04:33:22 +00:00
|
|
|
*
|
2024-06-20 18:44:20 +00:00
|
|
|
* @return {Promise} A promise that resolves once all tabs are created and appended to the tablist.
|
2024-05-25 04:33:22 +00:00
|
|
|
*/
|
|
|
|
createTabs() {
|
2024-06-20 18:44:20 +00:00
|
|
|
return new Promise( ( resolve ) => {
|
|
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
const tabpanels = this.tabber.querySelectorAll(
|
|
|
|
':scope > .tabber__section > .tabber__panel'
|
|
|
|
);
|
|
|
|
tabpanels.forEach( ( tabpanel ) => {
|
|
|
|
fragment.append( this.createTabElement( tabpanel ) );
|
|
|
|
} );
|
2024-05-25 04:33:22 +00:00
|
|
|
|
2024-06-20 18:44:20 +00:00
|
|
|
this.tablist.append( fragment );
|
|
|
|
this.tablist.classList.add( 'tabber__tabs' );
|
|
|
|
this.tablist.setAttribute( 'role', 'tablist' );
|
|
|
|
resolve();
|
|
|
|
} );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Creates the indicator element for the tabber.
|
|
|
|
*
|
|
|
|
* This method creates a div element to serve as the indicator for the active tab.
|
2024-06-20 18:44:20 +00:00
|
|
|
* The indicator element is given a specific CSS class for styling and is appended to the tabber header.
|
|
|
|
*
|
|
|
|
* @return {Promise} A promise that resolves once the indicator element is created.
|
2024-05-25 04:33:22 +00:00
|
|
|
*/
|
|
|
|
createIndicator() {
|
2024-06-20 18:44:20 +00:00
|
|
|
return new Promise( ( resolve ) => {
|
|
|
|
const indicator = document.createElement( 'div' );
|
|
|
|
indicator.classList.add( 'tabber__indicator' );
|
|
|
|
this.header.append( indicator );
|
|
|
|
resolve();
|
|
|
|
} );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2024-06-20 18:44:20 +00:00
|
|
|
* Creates the header for the tabber.
|
|
|
|
*
|
|
|
|
* This method creates two button elements, one for navigating to the previous tab and one for navigating to the next tab.
|
|
|
|
* Each button element is created with the specified class and aria-label attributes.
|
|
|
|
* The created buttons are appended to the header of the tabber.
|
2024-05-25 04:33:22 +00:00
|
|
|
*
|
2024-06-20 18:44:20 +00:00
|
|
|
* @return {Promise} A promise that resolves once the header is created.
|
2024-05-25 04:33:22 +00:00
|
|
|
*/
|
|
|
|
createHeader() {
|
2024-06-20 18:44:20 +00:00
|
|
|
return new Promise( ( resolve ) => {
|
|
|
|
/**
|
|
|
|
* Creates a button element with the specified class and aria-label.
|
|
|
|
*
|
|
|
|
* @param {string} className - The class name for the button element.
|
|
|
|
* @param {string} ariaLabel - The aria-label attribute for the button element.
|
|
|
|
* @return {Element} The created button element.
|
|
|
|
*/
|
|
|
|
const createButton = ( className, ariaLabel ) => {
|
|
|
|
const button = document.createElement( 'button' );
|
|
|
|
// eslint-disable-next-line mediawiki/class-doc
|
|
|
|
button.classList.add( className );
|
|
|
|
button.setAttribute( 'aria-label', ariaLabel );
|
|
|
|
return button;
|
|
|
|
};
|
|
|
|
|
|
|
|
const prevButton = createButton( 'tabber__header__prev', mw.message( 'tabberneue-button-prev' ).text() );
|
|
|
|
const nextButton = createButton( 'tabber__header__next', mw.message( 'tabberneue-button-next' ).text() );
|
|
|
|
|
|
|
|
this.header.append( prevButton, this.tablist, nextButton );
|
|
|
|
resolve();
|
|
|
|
} );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
2024-06-20 18:44:20 +00:00
|
|
|
* Initializes the tabber by creating tabs, header, and indicator elements sequentially.
|
2024-06-23 20:28:20 +00:00
|
|
|
* Sets the active tab based on the URL hash, and updates the header overflow.
|
|
|
|
* Attaches event listeners for tabber interaction.
|
|
|
|
*
|
|
|
|
* @param {string} urlHash - The URL hash used to set the active tab.
|
|
|
|
* @return {void}
|
2024-05-25 04:33:22 +00:00
|
|
|
*/
|
2024-06-23 20:28:20 +00:00
|
|
|
async init( urlHash ) {
|
2024-06-20 18:44:20 +00:00
|
|
|
// Create tabs, header, and indicator elements sequentially
|
|
|
|
await this.createTabs();
|
|
|
|
await this.createHeader();
|
|
|
|
await this.createIndicator();
|
|
|
|
|
2024-06-23 20:28:20 +00:00
|
|
|
const activeTab = this.tablist.querySelector( `#tab-${ urlHash }` ) || this.tablist.firstElementChild;
|
|
|
|
TabberAction.setActiveTab( activeTab );
|
2024-06-07 18:19:43 +00:00
|
|
|
TabberAction.updateHeaderOverflow( this.tablist );
|
2024-06-20 18:44:20 +00:00
|
|
|
|
|
|
|
// Start attaching event
|
2024-05-27 01:54:17 +00:00
|
|
|
setTimeout( () => {
|
|
|
|
const tabberEvent = new TabberEvent( this.tabber, this.tablist );
|
2024-05-27 02:29:20 +00:00
|
|
|
tabberEvent.init();
|
2024-06-20 18:44:20 +00:00
|
|
|
}, 0 );
|
|
|
|
|
|
|
|
this.tabber.classList.add( 'tabber--live' );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Loads tabbers with the given elements using the provided configuration.
|
|
|
|
*
|
|
|
|
* @param {NodeList} tabberEls - The elements representing tabbers to be loaded.
|
|
|
|
* @return {void}
|
|
|
|
*/
|
2024-06-20 18:44:20 +00:00
|
|
|
async function load( tabberEls ) {
|
2024-06-23 20:28:20 +00:00
|
|
|
const urlHash = window.location.hash.slice( 1 );
|
|
|
|
|
2024-05-25 04:33:22 +00:00
|
|
|
mw.loader.load( 'ext.tabberNeue.icons' );
|
|
|
|
|
|
|
|
Hash.init();
|
|
|
|
|
2024-06-23 20:28:20 +00:00
|
|
|
// eslint-disable-next-line compat/compat
|
|
|
|
resizeObserver = new ResizeObserver( TabberAction.onResize );
|
|
|
|
|
2024-06-20 18:44:20 +00:00
|
|
|
await Promise.all( [ ...tabberEls ].map( async ( tabberEl ) => {
|
2024-05-25 04:33:22 +00:00
|
|
|
const tabberBuilder = new TabberBuilder( tabberEl );
|
2024-06-23 20:28:20 +00:00
|
|
|
await tabberBuilder.init( urlHash );
|
2024-06-20 18:44:20 +00:00
|
|
|
} ) );
|
2024-05-25 04:33:22 +00:00
|
|
|
|
2024-05-25 19:27:47 +00:00
|
|
|
// Delay animation execution so it doesn't not animate the tab gets into position on load
|
|
|
|
setTimeout( () => {
|
2024-05-27 01:54:17 +00:00
|
|
|
TabberAction.toggleAnimation( true );
|
2024-05-25 19:27:47 +00:00
|
|
|
}, 250 );
|
2024-05-25 04:33:22 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
/**
|
|
|
|
* Main function that initializes the tabber functionality on the page.
|
|
|
|
* It selects all tabber elements that are not live, checks if there are any tabber elements
|
|
|
|
* present, and then calls the load function to load the tabber functionality on
|
|
|
|
* each tabber element.
|
|
|
|
*/
|
|
|
|
function main() {
|
|
|
|
const tabberEls = document.querySelectorAll( '.tabber:not(.tabber--live)' );
|
|
|
|
|
|
|
|
if ( tabberEls.length === 0 ) {
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
|
|
|
|
load( tabberEls );
|
|
|
|
}
|
|
|
|
|
|
|
|
mw.hook( 'wikipage.content' ).add( () => {
|
|
|
|
main();
|
|
|
|
} );
|
|
|
|
|
|
|
|
mw.loader.using( 'ext.visualEditor.desktopArticleTarget.init' ).done( () => {
|
|
|
|
// After saving edits
|
|
|
|
mw.hook( 'postEdit.afterRemoval' ).add( () => {
|
|
|
|
main();
|
|
|
|
} );
|
|
|
|
} );
|