mediawiki-extensions-Tabber.../modules/ext.tabberNeue/ext.tabberNeue.js
alistair3149 8a88a43d58
fix: make sure that init functions are run sequentially
Also fix the bug where the page does not scroll to the active tab on load by href.
It was introduced with the commit related to #133.

Closes: #148
2024-06-20 14:44:20 -04:00

737 lines
22 KiB
JavaScript

/**
* 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' );
const Hash = require( './Hash.js' );
const Transclude = require( './Transclude.js' );
const Util = require( './Util.js' );
let resizeObserver;
/**
* Class representing TabberAction functionality for handling tab events and animations.
*
* @class
*/
class TabberAction {
/**
* 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() {
return (
!window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ||
!config.enableAnimation
);
}
/**
* 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 ) {
if ( !TabberAction.shouldShowAnimation() ) {
return;
}
window.requestAnimationFrame( () => {
document.documentElement.classList.toggle(
'tabber-animations-ready',
enableAnimations
);
} );
}
/**
* 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.
*
* @param {Element} tablist - The tablist element in the tabber
*/
static updateHeaderOverflow( tablist ) {
const header = tablist.closest( '.tabber__header' );
const { roundScrollLeft } = Util;
const tablistWidth = tablist.offsetWidth;
const tablistScrollWidth = tablist.scrollWidth;
const isScrollable = tablistScrollWidth > tablistWidth;
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( () => {
header.classList.toggle(
'tabber__header--next-visible',
isAtStart || isAtMiddle
);
header.classList.toggle(
'tabber__header--prev-visible',
isAtEnd || isAtMiddle
);
} );
}
/**
* Animate and update the indicator position and width based on the active tab.
*
* @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.
*/
static animateIndicator( indicator, activeTab, tablist ) {
const tablistScrollLeft = Util.roundScrollLeft( tablist.scrollLeft );
const width = Util.getElementSize( activeTab, 'width' );
const transformValue = activeTab.offsetLeft - tablistScrollLeft;
window.requestAnimationFrame( () => {
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 );
} );
}
/**
* 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.
*/
static setActiveTabpanel( activeTabpanel ) {
const section = activeTabpanel.closest( '.tabber__section' );
if ( activeTabpanel.dataset.mwTabberLoadUrl ) {
const transclude = new Transclude( activeTabpanel );
transclude.loadPage();
}
window.requestAnimationFrame( () => {
const activeTabpanelHeight = Util.getElementSize(
activeTabpanel,
'height'
);
section.style.height = activeTabpanelHeight + 'px';
// Scroll to tab
section.scrollLeft = activeTabpanel.offsetLeft;
} );
}
/**
* Sets the active tab in the tabber element.
* 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.
*
* @param {Element} activeTab - The tab element to set as active.
* @return {Promise} - A promise that resolves once the active tab is set.
*/
static setActiveTab( activeTab ) {
return new Promise( ( resolve ) => {
const activeTabpanel = document.getElementById(
activeTab.getAttribute( 'aria-controls' )
);
const tabberEl = activeTabpanel.closest( '.tabber' );
const indicator = tabberEl.querySelector(
':scope > .tabber__header > .tabber__indicator'
);
const tabpanels = tabberEl.querySelectorAll(
':scope > .tabber__section > .tabber__panel'
);
const tabs = tabberEl.querySelectorAll(
':scope > .tabber__header > .tabber__tabs > .tabber__tab'
);
const tabStateUpdates = [];
const tabpanelVisibilityUpdates = [];
tabpanels.forEach( ( tabpanel ) => {
if ( tabpanel === activeTabpanel ) {
tabpanelVisibilityUpdates.push( {
element: tabpanel,
attributes: {
'aria-hidden': 'false',
tabindex: '0'
}
} );
} else {
tabpanelVisibilityUpdates.push( {
element: tabpanel,
attributes: {
'aria-hidden': 'true',
tabindex: '-1'
}
} );
}
} );
tabs.forEach( ( tab ) => {
if ( tab === activeTab ) {
tabStateUpdates.push( {
element: tab,
attributes: {
'aria-selected': true,
tabindex: '0'
}
} );
} else {
tabStateUpdates.push( {
element: tab,
attributes: {
'aria-selected': false,
tabindex: '-1'
}
} );
}
} );
window.requestAnimationFrame( () => {
tabpanelVisibilityUpdates.forEach( ( { element, attributes } ) => {
Util.setAttributes( element, attributes );
} );
tabStateUpdates.forEach( ( { element, attributes } ) => {
Util.setAttributes( element, attributes );
} );
TabberAction.animateIndicator(
indicator,
activeTab,
activeTab.parentElement
);
TabberAction.setActiveTabpanel( activeTabpanel );
} );
resolve();
} );
}
/**
* 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
* of the TabberAction class.
*
* @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 ) {
const tablist = button
.closest( '.tabber__header' )
.querySelector( '.tabber__tabs' );
const tablistWidth = tablist.offsetWidth;
const scrollOffset =
type === 'prev' ? -tablistWidth / 2 : tablistWidth / 2;
TabberAction.scrollTablist( scrollOffset, tablist );
}
/**
* 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.
*
* @param {ResizeObserverEntry[]} entries - An array of ResizeObserverEntry objects.
*/
static onResize( entries ) {
for ( const { target } of entries ) {
if ( target.classList.contains( 'tabber__tabs' ) ) {
TabberAction.updateHeaderOverflow( target );
} else if ( target.classList.contains( 'tabber__panel' ) ) {
TabberAction.setActiveTabpanel( target );
}
}
}
}
/**
* 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"]' );
this.indicator = this.tabber.querySelector(
':scope > .tabber__header > .tabber__indicator'
);
this.tabFocus = 0;
this.debouncedUpdateHeaderOverflow = mw.util.debounce(
() => TabberAction.updateHeaderOverflow( this.tablist ),
100
);
this.handleTabFocusChange = this.handleTabFocusChange.bind( this );
this.onHeaderClick = this.onHeaderClick.bind( this );
this.onTablistScroll = this.onTablistScroll.bind( this );
this.onTablistKeydown = this.onTablistKeydown.bind( this );
}
/**
* Returns the active tab panel element based on the currently active tab.
*
* @return {Element} The active tab panel element.
*/
getActiveTabpanel() {
return document.getElementById(
this.activeTab.getAttribute( 'aria-controls' )
);
}
/**
* Returns a debounced function that updates the header overflow.
*
* @return {Function} A debounced function that updates the header overflow.
*/
debounceUpdateHeaderOverflow() {
return this.debouncedUpdateHeaderOverflow;
}
/**
* Handles changing the focus to the tab based on the key pressed.
*
* @param {string} key - The key pressed ('home' or 'end' or 'right' or 'left').
*/
handleTabFocusChange( key ) {
this.tabs[ this.tabFocus ].setAttribute( 'tabindex', '-1' );
if ( key === 'home' ) {
this.tabFocus = 0;
} else if ( key === 'end' ) {
this.tabFocus = this.tabs.length - 1;
} else if ( key === 'right' ) {
this.tabFocus = ( this.tabFocus + 1 ) % this.tabs.length;
} else if ( key === 'left' ) {
this.tabFocus =
( this.tabFocus - 1 + this.tabs.length ) % this.tabs.length;
}
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.
*
* @param {Event} e - The click event object.
*/
onHeaderClick( e ) {
const tab = e.target.closest( '.tabber__tab' );
if ( tab ) {
// Prevent default anchor actions
e.preventDefault();
this.activeTab = tab;
// Update the URL hash without adding to browser history
if ( config.updateLocationOnTabChange ) {
history.replaceState(
null,
'',
window.location.pathname +
window.location.search +
'#' +
this.activeTab.getAttribute( 'aria-controls' )
);
}
TabberAction.setActiveTab( this.activeTab );
return;
}
const isPointerDevice = window.matchMedia( '(hover: hover)' ).matches;
if ( isPointerDevice ) {
const prevButton = e.target.closest( '.tabber__header__prev' );
if ( prevButton ) {
TabberAction.handleHeaderButton( prevButton, 'prev' );
return;
}
const nextButton = e.target.closest( '.tabber__header__next' );
if ( nextButton ) {
TabberAction.handleHeaderButton( nextButton, 'next' );
return;
}
}
}
/**
* Update the header overflow based on the scroll position of the tablist.
*/
onTablistScroll() {
this.debouncedUpdateHeaderOverflow();
}
/**
* Handles the keydown event on the tablist element.
* 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.
* 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.
*
* @param {Event} e - The keydown event object.
*/
onTablistKeydown( e ) {
if ( e.key === 'Home' ) {
e.preventDefault();
this.handleTabFocusChange( 'home' );
} else if ( e.key === 'End' ) {
e.preventDefault();
this.handleTabFocusChange( 'end' );
} else if ( e.key === 'ArrowRight' ) {
this.handleTabFocusChange( 'right' );
} else if ( e.key === 'ArrowLeft' ) {
this.handleTabFocusChange( 'left' );
}
}
/**
* Adds listeners for header click, tablist scroll, tablist keydown, and activeTabpanel resize.
*/
resume() {
this.header.addEventListener( 'click', this.onHeaderClick );
this.tablist.addEventListener( 'scroll', this.onTablistScroll );
this.tablist.addEventListener( 'keydown', this.onTablistKeydown );
resizeObserver.observe( this.tablist );
resizeObserver.observe( this.getActiveTabpanel() );
}
/**
* Removes listeners for header click, tablist scroll, tablist keydown, and activeTabpanel resize.
*/
pause() {
this.header.removeEventListener( 'click', this.onHeaderClick );
this.tablist.removeEventListener( 'scroll', this.onTablistScroll );
this.tablist.removeEventListener( 'keydown', this.onTablistKeydown );
resizeObserver.unobserve( this.tablist );
resizeObserver.unobserve( this.getActiveTabpanel() );
}
/**
* Initializes the TabberEvent instance by creating an IntersectionObserver to handle tabber visibility.
* When the tabber intersects with the viewport, it resumes all event listeners and observers.
* Otherwise, it pauses the event listeners and observers.
*/
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 );
this.resume();
}
}
/**
* 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',
role: 'tab',
'aria-selected': false,
'aria-controls': tabId,
href: '#' + tabId,
id: 'tab-' + tabId
};
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 = {
role: 'tabpanel',
'aria-labelledby': `tab-${ tabId }`,
id: tabId
};
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 ) {
mw.log.error(
'[TabberNeue] Missing or malformed `data-mw-tabber-title` attribute'
);
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 );
}
/**
* 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.
*
* @return {Promise} A promise that resolves once all tabs are created and appended to the tablist.
*/
createTabs() {
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 ) );
} );
this.tablist.append( fragment );
this.tablist.classList.add( 'tabber__tabs' );
this.tablist.setAttribute( 'role', 'tablist' );
resolve();
} );
}
/**
* Creates the indicator element for the tabber.
*
* This method creates a div element to serve as the indicator for the active tab.
* 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.
*/
createIndicator() {
return new Promise( ( resolve ) => {
const indicator = document.createElement( 'div' );
indicator.classList.add( 'tabber__indicator' );
this.header.append( indicator );
resolve();
} );
}
/**
* 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.
*
* @return {Promise} A promise that resolves once the header is created.
*/
createHeader() {
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();
} );
}
/**
* Initializes the tabber by creating tabs, header, and indicator elements sequentially.
* Sets the first tab as active, updates the header overflow, and adds the 'tabber--live' class to the tabber.
* Initializes tabber event and adds 'tabber--live' class.
*/
async init() {
// Create tabs, header, and indicator elements sequentially
await this.createTabs();
await this.createHeader();
await this.createIndicator();
// Get the first tab and set it as active
const firstTab = this.tablist.firstElementChild;
await TabberAction.setActiveTab( firstTab );
TabberAction.updateHeaderOverflow( this.tablist );
// Start attaching event
setTimeout( () => {
const tabberEvent = new TabberEvent( this.tabber, this.tablist );
tabberEvent.init();
}, 0 );
this.tabber.classList.add( 'tabber--live' );
}
}
/**
* Loads tabbers with the given elements using the provided configuration.
*
* @param {NodeList} tabberEls - The elements representing tabbers to be loaded.
* @return {void}
*/
async function load( tabberEls ) {
mw.loader.load( 'ext.tabberNeue.icons' );
Hash.init();
await Promise.all( [ ...tabberEls ].map( async ( tabberEl ) => {
const tabberBuilder = new TabberBuilder( tabberEl );
await tabberBuilder.init();
} ) );
const urlHash = window.location.hash.slice( 1 );
if ( Hash.exists( urlHash ) ) {
const activeTab = document.getElementById( `tab-${ urlHash }` );
const activeTabpanel = document.getElementById( urlHash );
await TabberAction.setActiveTab( activeTab );
window.requestAnimationFrame( () => {
activeTabpanel.scrollIntoView( {
behavior: 'auto',
block: 'end',
inline: 'nearest'
} );
} );
}
// eslint-disable-next-line compat/compat
resizeObserver = new ResizeObserver(
mw.util.debounce( TabberAction.onResize, 100 )
);
// Delay animation execution so it doesn't not animate the tab gets into position on load
setTimeout( () => {
TabberAction.toggleAnimation( true );
}, 250 );
}
/**
* 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();
} );
} );