mediawiki-extensions-Tabber.../modules/ext.tabberNeue.js

441 lines
13 KiB
JavaScript
Raw Normal View History

2021-06-21 17:49:47 +00:00
/**
2021-06-21 21:14:33 +00:00
* Initialize Tabber
*
* @param {HTMLElement} tabber
* @param {number} count
2021-06-21 17:49:47 +00:00
*/
function initTabber( tabber, count ) {
var ACTIVETABCLASS = 'tabber__tab--active';
var tabPanels = tabber.querySelectorAll( ':scope > .tabber__section > .tabber__panel' );
2021-06-21 21:14:33 +00:00
var config = require( './config.json' ),
header = tabber.querySelector( '.tabber__header' ),
2021-06-21 23:32:15 +00:00
tabList = document.createElement( 'nav' ),
2021-06-21 21:14:33 +00:00
prevButton = document.createElement( 'div' ),
nextButton = document.createElement( 'div' ),
indicator = document.createElement( 'div' );
2021-06-21 21:14:33 +00:00
var buildTabs = function () {
var fragment = new DocumentFragment();
var hashList = [];
2021-06-21 21:14:33 +00:00
Array.prototype.forEach.call( tabPanels, function ( tabPanel ) {
var hash = mw.util.escapeIdForAttribute( tabPanel.title ) + '-' + count,
2021-06-21 21:14:33 +00:00
tab = document.createElement( 'a' );
// add to list of already used hash
hashList.push( hash );
// check if the hash is already used before
var hashCount = 0;
hashList.forEach(
function ( h ) {
hashCount += ( h === hash ) ? 1 : 0;
}
);
// append counter if the same hash already used
hash += ( hashCount === 1 ) ? '' : ( '-' + hashCount );
2021-06-21 21:14:33 +00:00
tabPanel.setAttribute( 'id', hash );
tabPanel.setAttribute( 'role', 'tabpanel' );
tabPanel.setAttribute( 'aria-labelledby', 'tab-' + hash );
tabPanel.setAttribute( 'aria-hidden', true );
2021-06-21 21:14:33 +00:00
tab.innerText = tabPanel.title;
tab.classList.add( 'tabber__tab' );
2021-06-21 21:14:33 +00:00
tab.setAttribute( 'role', 'tab' );
tab.setAttribute( 'href', '#' + hash );
tab.setAttribute( 'id', 'tab-' + hash );
2022-04-30 21:14:44 +00:00
tab.setAttribute( 'aria-selected', false );
2021-06-21 21:14:33 +00:00
tab.setAttribute( 'aria-controls', hash );
fragment.append( tab );
2021-06-21 21:14:33 +00:00
} );
2021-06-21 23:32:15 +00:00
tabList.append( fragment );
tabList.classList.add( 'tabber__tabs' );
2021-06-21 23:32:15 +00:00
tabList.setAttribute( 'role', 'tablist' );
prevButton.classList.add( 'tabber__header__prev' );
nextButton.classList.add( 'tabber__header__next' );
indicator.classList.add( 'tabber__indicator' );
header.append( prevButton, tabList, nextButton, indicator );
2021-06-21 21:14:33 +00:00
};
2021-06-21 17:49:47 +00:00
var updateSectionHeight = function ( section, activePanel ) {
var height = activePanel.offsetHeight;
if ( height === 0 ) {
// Sometimes the tab is hidden by one of its parent elements
// and you can only get the actual height by cloning the element
var clone = activePanel.cloneNode( true );
// Hide the cloned element
clone.style.cssText = 'position:absolute;visibility:hidden;';
// Add cloned element to body
document.body.appendChild( clone );
// Measure the height of the clone
height = clone.clientHeight;
// Remove the cloned element
clone.parentNode.removeChild( clone );
}
section.style.height = String( height ) + 'px';
// Scroll to tab
section.scrollLeft = activePanel.offsetLeft;
};
var onElementResize = function ( entries ) {
if ( entries && entries.length > 0 ) {
var targetPanel = entries[ 0 ].target;
var section = targetPanel.parentNode;
updateSectionHeight( section, targetPanel );
}
};
var updateIndicator = function () {
var activeTab = tabList.querySelector( '.' + ACTIVETABCLASS );
// When the activeTab is visible in viewport, set the indicator
// IntersectionObserver is not supported in IE
// Probably time to drop IE support and move to ES6
/* eslint-disable-next-line compat/compat */
var observer = new IntersectionObserver( function ( entries ) {
entries.forEach( function ( entry ) {
if ( entry.isIntersecting ) {
indicator.style.width = activeTab.offsetWidth + 'px';
indicator.style.transform = 'translateX(' + ( activeTab.offsetLeft - tabList.scrollLeft ) + 'px)';
}
} );
}, tabList );
observer.observe( activeTab );
};
var resizeObserver = null;
if ( window.ResizeObserver ) {
resizeObserver = new ResizeObserver( mw.util.debounce( 250, onElementResize ) );
}
2021-06-21 21:14:33 +00:00
buildTabs();
tabber.prepend( header );
2021-06-21 21:14:33 +00:00
// Initalize previous and next buttons
var initButtons = function () {
var PREVCLASS = 'tabber__header--prev-visible',
2021-06-21 21:14:33 +00:00
NEXTCLASS = 'tabber__header--next-visible';
/* eslint-disable mediawiki/class-doc */
var scrollTabs = function ( offset ) {
var scrollLeft = tabList.scrollLeft + offset;
2021-06-21 21:14:33 +00:00
// Scroll to the start
if ( scrollLeft <= 0 ) {
2021-06-21 23:32:15 +00:00
tabList.scrollLeft = 0;
} else {
tabList.scrollLeft = scrollLeft;
}
};
var updateButtons = function () {
var scrollLeft = tabList.scrollLeft;
// Scroll to the start
if ( scrollLeft <= 0 ) {
header.classList.remove( PREVCLASS );
header.classList.add( NEXTCLASS );
2021-06-21 21:14:33 +00:00
} else {
// Scroll to the end
2021-06-21 23:32:15 +00:00
if ( scrollLeft + tabList.offsetWidth >= tabList.scrollWidth ) {
header.classList.remove( NEXTCLASS );
header.classList.add( PREVCLASS );
2021-06-21 17:49:47 +00:00
} else {
header.classList.add( NEXTCLASS );
header.classList.add( PREVCLASS );
}
}
2021-06-21 21:14:33 +00:00
};
var setupButtons = function () {
var isScrollable = ( tabList.scrollWidth > header.offsetWidth );
2021-06-21 21:14:33 +00:00
if ( isScrollable ) {
var scrollOffset = header.offsetWidth / 2;
2021-06-21 21:14:33 +00:00
// Just to add the right classes
updateButtons();
2021-06-21 21:14:33 +00:00
// Button only shows on pointer devices
if ( matchMedia( '(hover: hover)' ).matches ) {
prevButton.addEventListener( 'click', function () {
scrollTabs( -scrollOffset );
}, false );
nextButton.addEventListener( 'click', function () {
scrollTabs( scrollOffset );
}, false );
}
2021-06-21 21:14:33 +00:00
} else {
header.classList.remove( NEXTCLASS );
header.classList.remove( PREVCLASS );
2021-06-21 21:14:33 +00:00
}
};
/* eslint-enable mediawiki/class-doc */
2021-06-21 21:14:33 +00:00
setupButtons();
// Listen for scroll event on header
// Also triggered by side-scrolling using other means other than the buttons
tabList.addEventListener( 'scroll', function () {
updateButtons();
updateIndicator();
} );
// Add class to enable animation
// TODO: Change default to true when Safari bug is resolved
//
// Safari does not scroll when scroll-behavior: smooth and overflow: hidden
// Therefore the default is set to false now until it gets resolved
// https://bugs.webkit.org/show_bug.cgi?id=238497
if ( !config || config.enableAnimation ) {
tabber.classList.add( 'tabber--animate' );
}
// Listen for element resize
if ( window.ResizeObserver ) {
var tabListResizeObserver = new ResizeObserver( mw.util.debounce( 250, setupButtons ) );
tabListResizeObserver.observe( tabList );
}
2021-06-21 21:14:33 +00:00
};
// NOTE: Are there better ways to scope them?
var xhr = new XMLHttpRequest();
var currentRequest = null, nextRequest = null;
/**
* Loads page contents into tab
*
* @param {HTMLElement} targetPanel
* @param {string} url
*/
function loadPage( targetPanel, url ) {
var requestData = {
url: url,
targetPanel: targetPanel
};
if ( currentRequest ) {
if ( currentRequest.url !== requestData.url ) {
nextRequest = requestData;
}
// busy
return;
}
xhr.open( 'GET', url );
currentRequest = requestData;
xhr.send( null );
}
2021-06-21 21:14:33 +00:00
/**
* Show panel based on target hash
*
* @param {string} targetHash
* @param {boolean} allowRemoteLoad
* @param {boolean} scrollIntoView
2021-06-21 21:14:33 +00:00
*/
function showPanel( targetHash, allowRemoteLoad, scrollIntoView ) {
var ACTIVEPANELCLASS = 'tabber__panel--active',
2021-06-21 21:14:33 +00:00
targetPanel = document.getElementById( targetHash ),
targetTab = document.getElementById( 'tab-' + targetHash ),
section = targetPanel.parentNode,
activePanel = section.querySelector( ':scope > .' + ACTIVEPANELCLASS ),
parentPanel, parentSection;
var loadTransclusion = function () {
var loading = document.createElement( 'div' ),
loadingIndicator = document.createElement( 'div' );
targetPanel.setAttribute( 'aria-live', 'polite' );
targetPanel.setAttribute( 'aria-busy', 'true' );
loading.setAttribute( 'class', 'tabber__transclusion--loading' );
loadingIndicator.setAttribute( 'class', 'tabber__loading-indicator' );
loading.appendChild( loadingIndicator );
targetPanel.textContent = '';
targetPanel.appendChild( loading );
loadPage( targetPanel, targetPanel.dataset.tabberLoadUrl );
};
2021-06-21 21:14:33 +00:00
/* eslint-disable mediawiki/class-doc */
if ( activePanel ) {
// Just to be safe since there can be multiple active classes
// even if there shouldn't be
var activeTabs = tabList.querySelectorAll( '.' + ACTIVETABCLASS );
if ( activeTabs.length > 0 ) {
Array.prototype.forEach.call( activeTabs, function ( activeTab ) {
activeTab.classList.remove( ACTIVETABCLASS );
activeTab.setAttribute( 'aria-selected', false );
} );
2021-06-21 17:49:47 +00:00
}
if ( resizeObserver ) {
resizeObserver.unobserve( activePanel );
}
2021-06-21 21:14:33 +00:00
activePanel.classList.remove( ACTIVEPANELCLASS );
activePanel.setAttribute( 'aria-hidden', true );
2021-06-21 21:14:33 +00:00
}
// Add active class to the tab
2021-06-21 21:14:33 +00:00
targetTab.classList.add( ACTIVETABCLASS );
targetTab.setAttribute( 'aria-selected', true );
2021-06-21 21:14:33 +00:00
targetPanel.classList.add( ACTIVEPANELCLASS );
targetPanel.setAttribute( 'aria-hidden', false );
2021-06-21 21:14:33 +00:00
updateIndicator();
// Lazyload transclusion if needed
if ( allowRemoteLoad &&
targetPanel.dataset.tabberPendingLoad &&
targetPanel.dataset.tabberLoadUrl
) {
loadTransclusion();
}
updateSectionHeight( section, targetPanel );
// If we're inside another tab, trigger its logic to recalc its height
parentSection = section;
// ResizeObserver should take care of the recursivity already
/* eslint-disable-next-line no-unmodified-loop-condition */
while ( !resizeObserver ) {
parentPanel = parentSection.closest( '.' + ACTIVEPANELCLASS );
if ( !parentPanel ) {
break;
}
parentSection = parentPanel.parentNode;
updateSectionHeight( parentSection, parentPanel );
}
if ( resizeObserver ) {
resizeObserver.observe( targetPanel );
}
2021-06-21 21:14:33 +00:00
/* eslint-enable mediawiki/class-doc */
// If requested, scroll the tabber into view (browser fails to do that
// on its own as it tries to look up the anchor before we add it to the
// DOM)
if ( scrollIntoView ) {
targetTab.scrollIntoView();
}
2021-06-21 21:14:33 +00:00
}
/**
* Event handler for XMLHttpRequest where ends loading
*/
function onLoadEndPage() {
var targetPanel = currentRequest.targetPanel;
if ( xhr.status !== 200 ) {
var err = document.createElement( 'div' ),
errMsg = mw.message( 'error' ).text() + ': HTTP ' + xhr.status;
err.setAttribute( 'class', 'tabber__transclusion--error error' );
err.appendChild( document.createTextNode( errMsg ) );
targetPanel.textContent = '';
targetPanel.appendChild( err );
} else {
var result = JSON.parse( xhr.responseText );
targetPanel.innerHTML = result.parse.text;
// wikipage.content hook requires a jQuery object
/* eslint-disable-next-line no-undef */
mw.hook( 'wikipage.content' ).fire( $( targetPanel ) );
delete targetPanel.dataset.tabberPendingLoad;
delete targetPanel.dataset.tabberLoadUrl;
targetPanel.setAttribute( 'aria-busy', 'false' );
}
var ACTIVEPANELCLASS = 'tabber__panel--active',
targetHash = targetPanel.getAttribute( 'id' ),
section = targetPanel.parentNode,
activePanel = section.querySelector( ':scope > .' + ACTIVEPANELCLASS );
if ( nextRequest ) {
currentRequest = nextRequest;
nextRequest = null;
xhr.open( 'GET', currentRequest.url );
xhr.send( null );
} else {
currentRequest = null;
}
if ( activePanel ) {
// Refresh height
showPanel( targetHash, false );
}
}
xhr.timeout = 20000;
xhr.addEventListener( 'loadend', onLoadEndPage );
2021-06-21 21:14:33 +00:00
/**
* Retrieve target hash and trigger show panel
* If no targetHash is invalid, use the first panel
*
* @param {boolean} scrollIntoView
2021-06-21 21:14:33 +00:00
*/
function switchTab( scrollIntoView ) {
var targetHash = new mw.Uri( location.href ).fragment;
2021-06-21 21:14:33 +00:00
// Switch to the first tab if no targetHash or no tab is detected and do not scroll to it
// TODO: Remove the polyfill with CSS.escape when we are dropping IE support
if ( !targetHash || !tabList.querySelector( '#tab-' + targetHash.replace( /[^a-zA-Z0-9-_]/g, '\\$&' ) ) ) {
targetHash = tabList.firstElementChild.getAttribute( 'id' ).slice( 4 );
scrollIntoView = false;
2021-06-21 21:14:33 +00:00
}
showPanel( targetHash, false, scrollIntoView );
2021-06-21 21:14:33 +00:00
}
switchTab( true );
2021-06-21 21:14:33 +00:00
initButtons();
2021-06-21 21:14:33 +00:00
// window.addEventListener( 'hashchange', switchTab, false );
// Respond to clicks on the nav tabs
Array.prototype.forEach.call( tabList.children, function ( tab ) {
tab.addEventListener( 'click', function ( event ) {
var targetHash = tab.getAttribute( 'href' ).slice( 1 );
2021-06-21 21:14:33 +00:00
event.preventDefault();
if ( !config || config.updateLocationOnTabChange ) {
// Add hash to the end of the URL
history.replaceState( null, null, '#' + targetHash );
}
showPanel( targetHash, true );
2021-06-21 21:14:33 +00:00
} );
} );
2021-06-21 17:49:47 +00:00
2021-06-21 21:14:33 +00:00
tabber.classList.add( 'tabber--live' );
}
2021-06-21 17:49:47 +00:00
2021-06-21 21:14:33 +00:00
function main() {
var tabbers = document.querySelectorAll( '.tabber:not( .tabber--live )' ),
style = document.getElementById( 'tabber-style' );
2021-06-21 17:49:47 +00:00
2021-06-21 21:14:33 +00:00
if ( tabbers ) {
var count = 0;
2021-06-21 21:14:33 +00:00
mw.loader.load( 'ext.tabberNeue.icons' );
Array.prototype.forEach.call( tabbers, function ( tabber ) {
initTabber( tabber, count );
count++;
2021-06-21 17:49:47 +00:00
} );
// Remove critical render styles after done
// IE compatiblity
style.parentNode.removeChild( style );
2021-06-21 21:14:33 +00:00
}
}
2021-06-21 17:49:47 +00:00
if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
main();
} else {
document.addEventListener( 'DOMContentLoaded', function () {
main();
} );
}