mirror of
https://github.com/StarCitizenTools/mediawiki-extensions-TabberNeue.git
synced 2024-12-18 11:02:23 +00:00
f1e0df2112
This will allow us to animate the indicator separately. Which will be needed to make the indicator scroll with the panel.
429 lines
13 KiB
JavaScript
429 lines
13 KiB
JavaScript
/**
|
|
* Initialize Tabber
|
|
*
|
|
* @param {HTMLElement} tabber
|
|
* @param {number} count
|
|
*/
|
|
function initTabber( tabber, count ) {
|
|
var ACTIVETABCLASS = 'tabber__tab--active';
|
|
|
|
var tabPanels = tabber.querySelectorAll( ':scope > .tabber__section > .tabber__panel' );
|
|
|
|
var config = require( './config.json' ),
|
|
header = tabber.querySelector( '.tabber__header' ),
|
|
tabList = document.createElement( 'nav' ),
|
|
prevButton = document.createElement( 'div' ),
|
|
nextButton = document.createElement( 'div' ),
|
|
indicator = document.createElement( 'div' );
|
|
|
|
var buildTabs = function () {
|
|
var fragment = new DocumentFragment();
|
|
var hashList = [];
|
|
|
|
Array.prototype.forEach.call( tabPanels, function ( tabPanel ) {
|
|
var hash = mw.util.escapeIdForAttribute( tabPanel.title ) + '-' + count,
|
|
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 );
|
|
|
|
tabPanel.setAttribute( 'id', hash );
|
|
tabPanel.setAttribute( 'role', 'tabpanel' );
|
|
tabPanel.setAttribute( 'aria-labelledby', 'tab-' + hash );
|
|
tabPanel.setAttribute( 'aria-hidden', true );
|
|
|
|
tab.innerText = tabPanel.title;
|
|
tab.classList.add( 'tabber__tab' );
|
|
tab.setAttribute( 'role', 'tab' );
|
|
tab.setAttribute( 'href', '#' + hash );
|
|
tab.setAttribute( 'id', 'tab-' + hash );
|
|
tab.setAttribute( 'aria-selected', false );
|
|
tab.setAttribute( 'aria-controls', hash );
|
|
|
|
fragment.append( tab );
|
|
} );
|
|
|
|
tabList.append( fragment );
|
|
|
|
tabList.classList.add( 'tabber__tabs' );
|
|
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 );
|
|
};
|
|
|
|
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 );
|
|
indicator.style.width = activeTab.offsetWidth + 'px';
|
|
indicator.style.transform = 'translateX(' + ( activeTab.offsetLeft - tabList.scrollLeft ) + 'px)';
|
|
};
|
|
|
|
var resizeObserver = null;
|
|
if ( window.ResizeObserver ) {
|
|
resizeObserver = new ResizeObserver( mw.util.debounce( 250, onElementResize ) );
|
|
}
|
|
|
|
buildTabs();
|
|
tabber.prepend( header );
|
|
|
|
// Initalize previous and next buttons
|
|
var initButtons = function () {
|
|
var PREVCLASS = 'tabber__header--prev-visible',
|
|
NEXTCLASS = 'tabber__header--next-visible';
|
|
|
|
/* eslint-disable mediawiki/class-doc */
|
|
var scrollTabs = function ( offset ) {
|
|
var scrollLeft = tabList.scrollLeft + offset;
|
|
|
|
// Scroll to the start
|
|
if ( scrollLeft <= 0 ) {
|
|
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 );
|
|
} else {
|
|
// Scroll to the end
|
|
if ( scrollLeft + tabList.offsetWidth >= tabList.scrollWidth ) {
|
|
header.classList.remove( NEXTCLASS );
|
|
header.classList.add( PREVCLASS );
|
|
} else {
|
|
header.classList.add( NEXTCLASS );
|
|
header.classList.add( PREVCLASS );
|
|
}
|
|
}
|
|
};
|
|
|
|
var setupButtons = function () {
|
|
var isScrollable = ( tabList.scrollWidth > header.offsetWidth );
|
|
|
|
if ( isScrollable ) {
|
|
var scrollOffset = header.offsetWidth / 2;
|
|
|
|
// Just to add the right classes
|
|
updateButtons();
|
|
|
|
// 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 );
|
|
}
|
|
} else {
|
|
header.classList.remove( NEXTCLASS );
|
|
header.classList.remove( PREVCLASS );
|
|
}
|
|
};
|
|
/* eslint-enable mediawiki/class-doc */
|
|
|
|
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 );
|
|
}
|
|
};
|
|
|
|
// 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 );
|
|
}
|
|
|
|
/**
|
|
* Show panel based on target hash
|
|
*
|
|
* @param {string} targetHash
|
|
* @param {boolean} allowRemoteLoad
|
|
* @param {boolean} scrollIntoView
|
|
*/
|
|
function showPanel( targetHash, allowRemoteLoad, scrollIntoView ) {
|
|
var ACTIVEPANELCLASS = 'tabber__panel--active',
|
|
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 );
|
|
};
|
|
|
|
/* 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 );
|
|
} );
|
|
}
|
|
|
|
if ( resizeObserver ) {
|
|
resizeObserver.unobserve( activePanel );
|
|
}
|
|
|
|
activePanel.classList.remove( ACTIVEPANELCLASS );
|
|
activePanel.setAttribute( 'aria-hidden', true );
|
|
}
|
|
|
|
// Add active class to the tab
|
|
targetTab.classList.add( ACTIVETABCLASS );
|
|
targetTab.setAttribute( 'aria-selected', true );
|
|
targetPanel.classList.add( ACTIVEPANELCLASS );
|
|
targetPanel.setAttribute( 'aria-hidden', false );
|
|
|
|
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 );
|
|
}
|
|
/* 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();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 );
|
|
|
|
/**
|
|
* Retrieve target hash and trigger show panel
|
|
* If no targetHash is invalid, use the first panel
|
|
*
|
|
* @param {boolean} scrollIntoView
|
|
*/
|
|
function switchTab( scrollIntoView ) {
|
|
var targetHash = new mw.Uri( location.href ).fragment;
|
|
|
|
// 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;
|
|
}
|
|
|
|
showPanel( targetHash, false, scrollIntoView );
|
|
}
|
|
|
|
switchTab( true );
|
|
|
|
initButtons();
|
|
|
|
// 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 );
|
|
event.preventDefault();
|
|
if ( !config || config.updateLocationOnTabChange ) {
|
|
// Add hash to the end of the URL
|
|
history.replaceState( null, null, '#' + targetHash );
|
|
}
|
|
showPanel( targetHash, true );
|
|
} );
|
|
} );
|
|
|
|
tabber.classList.add( 'tabber--live' );
|
|
}
|
|
|
|
function main() {
|
|
var tabbers = document.querySelectorAll( '.tabber:not( .tabber--live )' ),
|
|
style = document.getElementById( 'tabber-style' );
|
|
|
|
if ( tabbers ) {
|
|
var count = 0;
|
|
mw.loader.load( 'ext.tabberNeue.icons' );
|
|
Array.prototype.forEach.call( tabbers, function ( tabber ) {
|
|
initTabber( tabber, count );
|
|
count++;
|
|
} );
|
|
// Remove critical render styles after done
|
|
// IE compatiblity
|
|
style.parentNode.removeChild( style );
|
|
}
|
|
}
|
|
|
|
if ( document.readyState === 'interactive' || document.readyState === 'complete' ) {
|
|
main();
|
|
} else {
|
|
document.addEventListener( 'DOMContentLoaded', function () {
|
|
main();
|
|
} );
|
|
}
|