feat: rewrite Javascript implementation

To ensure easier development and maintenance, IE support is dropped to allow
modern Javascript features. The old tabber module is rewritten into newer
standard and using classes to ensure proper scoping.
This commit is contained in:
alistair3149 2024-05-25 00:33:22 -04:00
parent 9f44a9079d
commit bb110c6d98
No known key found for this signature in database
19 changed files with 1944 additions and 5002 deletions

View file

@ -1,21 +1,6 @@
{
"root": true,
"extends": [
"wikimedia/client-common",
"wikimedia/language/es6",
"wikimedia/vue3-es6",
"wikimedia/mediawiki"
],
"globals": {
"require": "readonly",
"module": "readonly"
},
"rules": {
"one-var": "off",
"//": [
"off",
"ResourceLoader's `packageFiles` do not require wrapping but the `module` option is only available in ES6+."
],
"no-implicit-globals": "off"
}
"wikimedia/server"
]
}

View file

@ -45,11 +45,11 @@
"mobile"
]
},
"ext.tabberNeue.legacy": {
"ext.tabberNeue": {
"packageFiles": [
"ext.tabberNeue.legacy/ext.tabberNeue.legacy.js",
"ext.tabberNeue/ext.tabberNeue.js",
{
"name": "ext.tabberNeue.legacy/config.json",
"name": "ext.tabberNeue/config.json",
"config": {
"enableAnimation": "TabberNeueEnableAnimation",
"parseTabName": "TabberNeueParseTabName",
@ -61,8 +61,8 @@
"error"
],
"styles": [
"ext.tabberNeue.legacy/ext.tabberNeue.legacy.less",
"ext.tabberNeue.legacy/ext.tabberNeue.legacy.mobile.less"
"ext.tabberNeue/ext.tabberNeue.less",
"ext.tabberNeue/ext.tabberNeue.mobile.less"
],
"dependencies": [
"mediawiki.Uri",

View file

@ -66,7 +66,7 @@ class Tabber {
// See ext.tabberNeue.inline.less
$style = sprintf( '<style id="tabber-style">%s</style>', self::$criticalInlineStyle );
$parser->getOutput()->addHeadItem( $style, true );
$parser->getOutput()->addModules( [ 'ext.tabberNeue.legacy' ] );
$parser->getOutput()->addModules( [ 'ext.tabberNeue' ] );
}
$parser->addTrackingCategory( 'tabberneue-tabber-category' );
@ -150,7 +150,7 @@ class Tabber {
}
}
// Legacy mode
// Normal mode
if ( self::$parseTabName ) {
$tabName = $parser->recursiveTagParseFully( $tabName );
$tabName = $parser->stripOuterParagraph( $tabName );

View file

@ -42,7 +42,7 @@ class TabberTransclude {
// See ext.tabberNeue.inline.less
$style = sprintf( '<style id="tabber-style">%s</style>', Tabber::$criticalInlineStyle );
$parser->getOutput()->addHeadItem( $style, true );
$parser->getOutput()->addModules( [ 'ext.tabberNeue.legacy' ] );
$parser->getOutput()->addModules( [ 'ext.tabberNeue' ] );
$parser->addTrackingCategory( 'tabberneue-tabbertransclude-category' );
return $html;
@ -118,7 +118,6 @@ class TabberTransclude {
'<div class="tabber__transclusion">%s</div>',
$linkRenderer->makeLink( $title, null, [ 'rel' => 'nofollow' ] )
);
$dataProps['pending-load'] = '1';
$currentTitle = $parser->getPage();
$query = sprintf(
'?action=parse&format=json&formatversion=2&title=%s&text={{:%s}}&redirects=1&prop=text&disablelimitreport=1&disabletoc=1&wrapoutputclass=',
@ -151,7 +150,7 @@ class TabberTransclude {
$tab = '<article class="tabber__panel" data-mw-tabber-title="' . htmlspecialchars( $tabName ) . '"';
$tab .= implode( array_map( static function ( $prop, $value ) {
return sprintf( ' data-tabber-%s="%s"', $prop, htmlspecialchars( $value ) );
return sprintf( ' data-mw-tabber-%s="%s"', $prop, htmlspecialchars( $value ) );
}, array_keys( $dataProps ), $dataProps ) );
$tab .= '>' . $tabBody . '</article>';
$selected = false;

19
modules/.eslintrc.json Normal file
View file

@ -0,0 +1,19 @@
{
"extends": [
"wikimedia/client-es6",
"wikimedia/mediawiki"
],
"env": {
"browser": true
},
"globals": {
"exports": true
},
"parserOptions": {
"sourceType": "module"
},
"rules": {
"jsdoc/no-undefined-types": "off",
"max-len": "off"
}
}

View file

@ -0,0 +1,9 @@
{
"plugins": [
"no-jquery"
],
"extends": [
"plugin:no-jquery/all",
"wikimedia/vue3-es6"
]
}

View file

@ -1,18 +1,17 @@
<template>
<cdx-tabs v-model:active="currentTab" :framed="framed">
<cdx-tab
<CdxTabs v-model:active="currentTab" :framed="framed">
<CdxTab
v-for="( tab, index ) in tabsData"
:key="index"
:name="tab.name"
:label="tab.label"
:disabled="tab.disabled"
>
<tab-content
<TabContent
:html="tab.content"
>
</tab-content>
</cdx-tab>
</cdx-tabs>
></TabContent>
</CdxTab>
</CdxTabs>
</template>
<script>

View file

@ -1,6 +1,6 @@
<template>
<cdx-tabs v-if="isChildTabber && tabsData.length > 0" v-model:active="currentTab">
<cdx-tab
<CdxTabs v-if="isChildTabber && tabsData.length > 0" v-model:active="currentTab">
<CdxTab
v-for="( tab, index ) in tabsData"
:key="index"
:name="escapeId( tab.label )"
@ -8,15 +8,13 @@
>
<tab-content
:html="tab.content"
>
</tab-content>
</cdx-tab>
</cdx-tabs>
></tab-content>
</CdxTab>
</CdxTabs>
<div
v-else
v-html="html"
>
</div>
></div>
</template>
<script>

View file

@ -1,13 +1,14 @@
/*
/*
* Override default MW styles
* * .mw-content-ltr ul
* * li
*/
/* stylelint-disable selector-class-pattern */
.tabber .cdx-tabs__list,
.tabber .cdx-tabs__list__item {
margin: 0;
.tabber .cdx-tabs__list__item {
margin: 0;
}
.tabber .cdx-tabs__list__item {
margin: 0;
margin: 0;
}

View file

@ -1,543 +0,0 @@
const ACTIVETAB_SELECTOR = '[aria-selected="true"]';
const ACTIVEPANEL_SELECTOR = '[aria-hidden="false"]';
const sizeProperties = Object.freeze( {
width: 'offsetWidth',
height: 'offsetHeight'
} );
// Temp element used by various extractTextFromHtml
const tempElement = document.createElement( 'div' );
/**
* Rounds the scrollLeft value to the nearest integer using Math.ceil.
* scrollLeft can return decimals while offset are always integer
*
* @param {number} val - The scrollLeft value to be rounded.
* @return {number} The rounded scrollLeft value.
*/
function roundScrollLeft( val ) {
return Math.ceil( val );
}
/**
* Parses the given HTML string and returns the text content of the body element.
*
* @param {string} html - The HTML string to parse
* @return {string} The text content of the body element in the parsed document
*/
function extractTextFromHtml( html ) {
tempElement.innerHTML = html;
return tempElement.textContent;
}
/**
* Returns the actual size (width or height) of the provided element.
*
* @param {Element} element - The element for which to get the size.
* @param {string} type - The type of size to retrieve ('width' or 'height').
* @return {number} The actual size of the element based on the specified type.
*/
function getActualSize( element, type ) {
if ( !element || !( element instanceof Element ) || ( type !== 'width' && type !== 'height' ) ) {
mw.log.error( '[TabberNeue] Invalid element or type provided for getActualSize' );
return 0;
}
let value = element[ sizeProperties[ type ] ];
if ( value === 0 ) {
const clone = element.cloneNode( true );
clone.style.position = 'absolute';
clone.style.visibility = 'hidden';
document.body.appendChild( clone );
value = clone[ sizeProperties[ type ] ];
clone.parentNode.removeChild( clone );
}
return value;
}
/**
* Updates the height of a section based on the height of the provided panel.
*
* @param {Element} section - The section element to update the height for.
* @param {Element} panel - The panel element whose height will be used to
* update the section height.
* @return {void}
*/
function updateSectionHeight( section, panel ) {
/* Exit early if it is not the active panel */
if ( panel.getAttribute( 'aria-hidden' ) !== 'false' ) {
return;
}
const height = getActualSize( panel, 'height' );
window.requestAnimationFrame( function () {
section.style.height = height + 'px';
// Scroll to tab
section.scrollLeft = panel.offsetLeft;
} );
}
/**
* Handles the resize event for an element.
*
* @param {Array} entries - An array of ResizeObserverEntry objects representing
* the elements being resized.
* @return {void}
*/
function onElementResize( entries ) {
if ( entries && entries.length > 0 ) {
const targetPanel = entries[ 0 ].target;
if ( targetPanel instanceof Element ) {
const section = targetPanel.parentNode;
updateSectionHeight( section, targetPanel );
}
}
}
/**
* Initialize Tabber
*
* @param {HTMLElement} tabber
* @param {number} count
*/
function initTabber( tabber, count ) {
const
config = require( './config.json' ),
header = tabber.querySelector( ':scope > .tabber__header' ),
tabList = document.createElement( 'nav' ),
prevButton = document.createElement( 'div' ),
nextButton = document.createElement( 'div' ),
indicator = document.createElement( 'div' );
/**
* Function to build tabs for a tabber component.
* It iterates over tab panels, creates corresponding tab elements,
* assigns necessary attributes, and appends them to the tab list.
*/
const buildTabs = function () {
const tabPanels = tabber.querySelectorAll( ':scope > .tabber__section > .tabber__panel' );
const fragment = document.createDocumentFragment();
const hashList = [];
Array.prototype.forEach.call( tabPanels, function ( tabPanel ) {
const tab = document.createElement( 'a' );
let title = tabPanel.getAttribute( 'data-mw-tabber-title' );
if ( config && config.parseTabName ) {
tab.innerHTML = title;
title = extractTextFromHtml( title );
} else {
tab.textContent = title;
}
let hash = mw.util.escapeIdForAttribute( title ) + '-' + count;
// add to list of already used hash
hashList.push( hash );
// check if the hash is already used before
let 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.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 );
};
const updateIndicator = function ( showTransition ) {
// Select the first tab when there are no active tab (e.g. page preview)
const activeTab = tabList.querySelector( ACTIVETAB_SELECTOR ) || tabList.querySelector( '.tabber__tab' );
const width = getActualSize( activeTab, 'width' );
indicator.style.width = width + 'px';
indicator.style.transform = 'translateX(' + ( activeTab.offsetLeft - roundScrollLeft( tabList.scrollLeft ) ) + 'px)';
indicator.style.transition = '';
// Do not animate when user prefers reduced motion
if ( window.matchMedia( '(prefers-reduced-motion: reduce)' ).matches ) {
return;
}
if ( showTransition ) {
indicator.style.transition = 'transform 250ms ease, width 250ms ease';
}
};
let resizeObserver = null;
if ( window.ResizeObserver ) {
resizeObserver = new ResizeObserver( mw.util.debounce( 250, onElementResize ) );
}
buildTabs();
tabber.prepend( header );
// Initalize previous and next buttons
const initHeaderButtons = function () {
const updateTabsNavigation = function () {
/* eslint-disable mediawiki/class-doc */
const
PREVCLASS = 'tabber__header--prev-visible',
NEXTCLASS = 'tabber__header--next-visible';
const isScrollable = ( tabList.scrollWidth > header.offsetWidth );
if ( !isScrollable ) {
header.classList.remove( NEXTCLASS );
header.classList.remove( PREVCLASS );
return;
}
const scrollLeft = roundScrollLeft( 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 );
}
}
/* eslint-enable mediawiki/class-doc */
};
updateTabsNavigation();
// Set up click event listener
header.addEventListener( 'click', function ( event ) {
// Tab button
if ( event.target.classList.contains( 'tabber__tab' ) ) {
const targetHash = event.target.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 );
// Handle tab navigation buttons when device uses a pointer device
} else if ( matchMedia( '(hover: hover)' ).matches ) {
const scrollOffset = header.offsetWidth / 2;
const scrollTabs = function ( offset ) {
const scrollLeft = roundScrollLeft( tabList.scrollLeft ) + offset;
// Scroll to the start
if ( scrollLeft <= 0 ) {
tabList.scrollLeft = 0;
} else {
tabList.scrollLeft = scrollLeft;
}
};
// Prev button
if ( event.target.classList.contains( 'tabber__header__prev' ) ) {
scrollTabs( -scrollOffset );
// Next button
} else if ( event.target.classList.contains( 'tabber__header__next' ) ) {
scrollTabs( scrollOffset );
}
}
} );
// Listen for scroll event on header
// Also triggered by side-scrolling using other means other than the buttons
tabList.addEventListener( 'scroll', function () {
window.requestAnimationFrame( function () {
updateTabsNavigation();
updateIndicator( false );
} );
} );
// 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 ) {
const tabListResizeObserver = new ResizeObserver(
mw.util.debounce( 250, updateTabsNavigation )
);
tabListResizeObserver.observe( tabList );
}
};
// NOTE: Are there better ways to scope them?
const xhr = new XMLHttpRequest();
let currentRequest = null, nextRequest = null;
/**
* Loads page contents into tab
*
* @param {HTMLElement} targetPanel
* @param {string} url
*/
function loadPage( targetPanel, url ) {
const 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 ) {
const
targetPanel = document.getElementById( targetHash ),
targetTab = document.getElementById( 'tab-' + targetHash ),
section = targetPanel.parentNode,
activePanel = section.querySelector( ':scope > ' + ACTIVEPANEL_SELECTOR );
let parentPanel, parentSection;
const loadTransclusion = function () {
const
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 );
};
if ( activePanel ) {
// Just to be safe since there can be multiple active tabs
// even if there shouldn't be
const activeTabs = tabList.querySelectorAll( ACTIVETAB_SELECTOR );
if ( activeTabs.length > 0 ) {
Array.prototype.forEach.call( activeTabs, function ( activeTab ) {
activeTab.setAttribute( 'aria-selected', false );
} );
}
if ( resizeObserver ) {
resizeObserver.unobserve( activePanel );
}
activePanel.setAttribute( 'aria-hidden', true );
}
// Add active class to the tab
targetTab.setAttribute( 'aria-selected', true );
targetPanel.setAttribute( 'aria-hidden', false );
updateIndicator( true );
// 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
parentPanel = parentSection.closest( ACTIVEPANEL_SELECTOR );
/* eslint-disable-next-line no-unmodified-loop-condition */
while ( !resizeObserver && parentPanel ) {
parentSection = parentPanel.parentNode;
updateSectionHeight( parentSection, parentPanel );
parentPanel = parentSection.closest( ACTIVEPANEL_SELECTOR );
}
if ( resizeObserver ) {
resizeObserver.observe( targetPanel );
}
// 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() {
const targetPanel = currentRequest.targetPanel;
if ( xhr.status !== 200 ) {
const 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 {
const 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' );
}
const targetHash = targetPanel.getAttribute( 'id' ),
section = targetPanel.parentNode,
activePanel = section.querySelector( ':scope > ' + ACTIVEPANEL_SELECTOR );
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 ) {
let 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 );
initHeaderButtons();
// window.addEventListener( 'hashchange', switchTab, false );
tabber.classList.add( 'tabber--live' );
}
/**
* Initializes tabbers by loading icons and calling initTabber function for each tabber element.
*
* @param {NodeList} tabbers - List of tabber elements to initialize
* @return {void}
*/
function initTabbers( tabbers ) {
let count = 0;
mw.loader.load( 'ext.tabberNeue.icons' );
Array.prototype.forEach.call( tabbers, function ( tabber ) {
initTabber( tabber, count );
count++;
} );
const style = document.getElementById( 'tabber-style' );
// Remove critical render styles after done
if ( style ) {
// IE compatiblity
style.parentNode.removeChild( style );
}
}
/**
* Function to initialize tabbers on the page.
* It selects all tabbers that are not live and calls the initTabbers function on them.
*
* @return {void}
*/
function main() {
const tabbers = document.querySelectorAll( '.tabber:not(.tabber--live)' );
if ( tabbers.length === 0 ) {
return;
}
initTabbers( tabbers );
}
mw.hook( 'wikipage.content' ).add( function () {
main();
} );
/*
* Add hooks for Tabber when Visual Editor is used.
*/
mw.loader.using( 'ext.visualEditor.desktopArticleTarget.init', function () {
// After saving edits
mw.hook( 'postEdit.afterRemoval' ).add( () => {
main();
} );
} );

View file

@ -0,0 +1,14 @@
{
"plugins": [
"no-jquery"
],
"parserOptions": {
"ecmaVersion": 2017
},
"extends": [
"plugin:no-jquery/all"
],
"rules": {
"es-x/no-async-functions": "off"
}
}

View file

@ -0,0 +1,3 @@
{
"@doc": "This file describes the shape of the config. It exists to support eslint."
}

View file

@ -18,7 +18,7 @@
opacity: 0.1;
}
.client-js .tabber__header:after {
.client-js .tabber__header::after {
position: absolute;
width: 16ch;
height: 0.5em;
@ -34,7 +34,7 @@
display: none;
}
/*
/*
* Hide all other panels
* All panels are stacked vertically initially
* then panels are stacked horizontally after Tabber is loaded

View file

@ -0,0 +1,791 @@
/**
* 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' );
let uniqueHashes;
let resizeObserver;
/**
* Class representing a Hash utility for generating unique hash values.
*
* @class Hash
*/
class Hash {
/**
* Initializes the Hash class by creating a new Set to store unique hashes.
*/
static init() {
uniqueHashes = new Set();
}
/**
* Checks if a given hash is not unique by verifying if it exists in the Set of unique hashes.
*
* @param {string} hash - The hash to check for uniqueness.
* @return {boolean} - Returns true if the hash is not unique, false otherwise.
*/
static exists( hash ) {
return uniqueHashes.has( hash );
}
/**
* Generates a unique hash based on the input hash by appending a suffix if necessary.
*
* @param {string} hash - The base hash to make unique.
* @return {string} - A unique hash derived from the input hash.
*/
static makeUnique( hash ) {
const match = hash.match( /^(.+)_([0-9]+)$/ );
let suffix = match ? parseInt( match[ 2 ], 10 ) + 1 : 1;
const initialHash = hash;
let uniqueHash = `${ initialHash }_${ suffix }`;
// Increment suffix and generate a new unique hash until a unique one is found
while ( Hash.exists( uniqueHash ) ) {
suffix++;
uniqueHash = `${ initialHash }_${ suffix }`;
}
return uniqueHash;
}
/**
* Builds a unique hash based on the provided title text.
*
* @param {string} titleText - The title text to generate the hash from.
* @return {string} - A unique hash created from the title text.
*/
static build( titleText ) {
let hash = mw.util.escapeIdForAttribute( titleText );
if ( Hash.exists( hash ) ) {
hash = Hash.makeUnique( hash );
}
uniqueHashes.add( hash );
return hash;
}
/**
* Clears the Set of unique hashes, removing all stored hashes.
*/
static clear() {
uniqueHashes.clear();
}
}
/**
* Utility class with methods for common utility functions.
*
* @class Util
*/
class Util {
/**
* Extracts text content from the given HTML string.
*
* @param {string} html - The HTML string to extract text content from.
* @return {string} The extracted text content.
*/
static extractTextFromHtml( html ) {
const tmp = document.createElement( 'div' );
tmp.innerHTML = html;
return tmp.textContent;
}
/**
* Returns the size (width or height) of the provided element.
* Required to calculate the size of hidden elements (e.g. nested tabs)
*
* @param {Element} element - The element for which to get the size.
* @param {string} type - The type of size to retrieve ('width' or 'height').
* @return {number} The actual size of the element based on the specified type.
*/
static getElementSize( element, type ) {
if ( !element || !( element instanceof Element ) || ( type !== 'width' && type !== 'height' ) ) {
mw.log.error( '[TabberNeue] Invalid element or type provided for getElementSize' );
return 0;
}
let value = element.getBoundingClientRect()[ type ];
if ( value === 0 ) {
value = this.getHiddenElementSize( element, type );
}
return value;
}
/**
* Retrieves the size of a hidden element by cloning it and calculating the size.
*
* @param {Element} element - The hidden element to retrieve the size from.
* @param {string} type - The type of size to retrieve ('width' or 'height').
* @return {number} The size of the hidden element based on the specified type.
*/
static getHiddenElementSize( element, type ) {
const shadowRoot = document.createElement( 'div' ).attachShadow( { mode: 'open' } );
const clone = element.cloneNode( true );
clone.style.position = 'absolute';
clone.style.visibility = 'hidden';
shadowRoot.appendChild( clone );
try {
const value = clone.getBoundingClientRect()[ type ];
return value;
} finally {
clone.parentNode.removeChild( clone );
}
}
/**
* Rounds the scrollLeft value to the nearest integer using Math.ceil.
* Used to avoid the fractional pixel issue caused by different browser implementations
*
* @param {number} val - The scrollLeft value to be rounded.
* @return {number} The rounded scrollLeft value.
*/
static roundScrollLeft( val ) {
return Math.ceil( val );
}
/**
* Sets the attributes of the given element based on the provided attributes object.
*
* @param {Element} element - The element to set attributes for.
* @param {Object} attributes - An object containing key-value pairs of attributes to set.
*/
static setAttributes( element, attributes ) {
for ( const key in attributes ) {
element.setAttribute( key, attributes[ key ] );
}
}
}
/**
* Represents a class that handles transcluding content for a tab within a tabber component.
*
* @class TabberTransclude
*/
class TabberTransclude {
constructor( activeTabpanel, cacheExpiration = 3600 ) {
this.activeTabpanel = activeTabpanel;
this.pageTitle = this.activeTabpanel.dataset.mwTabberPageTitle;
this.url = this.activeTabpanel.dataset.mwTabberLoadUrl;
this.cacheKey = `tabber-transclude-${ encodeURIComponent( this.pageTitle ) }_v1`;
this.cacheExpiration = cacheExpiration;
}
/**
* Validates the URL format.
*
* @return {Promise} A Promise that resolves if the URL is valid, and rejects with an Error if the URL is empty, null, or in an invalid format.
*/
validateUrl() {
const urlPattern = /^(https?):\/\/[^\s/$.?#][^\s]*$/;
if ( !this.url || this.url.trim() === '' ) {
return Promise.reject( new Error( '[TabberNeue] URL is empty or null' ) );
}
if ( !urlPattern.test( this.url ) ) {
return Promise.reject( new Error( `[TabberNeue] Invalid URL format : ${ this.url }` ) );
}
return Promise.resolve();
}
/**
* Checks the session storage for cached data using the cache key.
*
* @return {Object|null} The cached data if found, or null if no cached data is found.
*/
checkCache() {
const cachedData = mw.storage.session.getObject( this.cacheKey );
if ( cachedData ) {
return cachedData;
}
return null;
}
/**
* Fetches data from the specified URL using a GET request.
*
* @return {Promise} A Promise that resolves with the response text if the network request is successful,
* and rejects with an Error if there is an issue with the network request.
*/
async fetchDataFromUrl() {
try {
const response = await fetch( this.url, { method: 'GET', timeout: 5000, credentials: 'same-origin' } );
if ( !response.ok ) {
throw new Error( `Network response was not ok: ${ response.status } - ${ response.statusText }` );
}
return Promise.resolve( response.text() );
} catch ( error ) {
return Promise.reject( `[TabberNeue] Error fetching data from URL: ${ this.url }`, error );
}
}
/**
* Parses the JSON data and extracts the 'parse.text' property.
*
* @param {string} data - The JSON data to be parsed.
* @return {string} The parsed 'parse.text' property from the JSON data.
* @throws {Error} If an error occurs while parsing the JSON data.
*/
parseData( data ) {
let parsedData;
try {
parsedData = JSON.parse( data );
parsedData = parsedData.parse.text;
} catch ( error ) {
mw.log.error( `[TabberNeue] Error occurred while parsing JSON data: ${ error }` );
return Promise.reject( new Error( `Error parsing JSON data: ${ error }` ) );
}
return parsedData;
}
/**
* Caches the parsed data in the session storage using the cache key.
*
* @param {string} parsedData - The parsed data to be cached.
* @return {string} The cached parsed data.
*/
cacheData( parsedData ) {
mw.storage.session.setObject( this.cacheKey, parsedData, this.cacheExpiration );
return parsedData;
}
/**
* Fetches data by validating the URL, checking the cache, fetching data from the URL,
* parsing the data, and caching the parsed data if not found in the cache.
*
* @return {Promise} A Promise that resolves with the fetched and cached data,
* or rejects with an error message if any step fails.
*/
async fetchData() {
try {
await this.validateUrl();
const cachedData = this.checkCache();
if ( cachedData ) {
return cachedData;
}
const data = await this.fetchDataFromUrl();
const parsedData = this.parseData( data );
return this.cacheData( parsedData );
} catch ( error ) {
return Promise.reject( `[TabberNeue] Error fetching data: ${ error }` );
}
}
/**
* Loads the page content by fetching data, updating the active tab panel's content,
* and handling errors if data fetching fails.
*
* @return {void}
*/
async loadPage() {
try {
this.activeTabpanel.classList.add( 'tabber__panel--loading' );
const data = await this.fetchData();
if ( data ) {
delete this.activeTabpanel.dataset.mwTabberLoadUrl;
this.activeTabpanel.classList.remove( 'tabber__panel--loading' );
this.activeTabpanel.innerHTML = data;
} else {
mw.log.error( `[TabberNeue] No valid API response or missing 'parse' field for ${ this.pageTitle } from: ${ this.url }` );
}
} catch ( error ) {
mw.log.error( `[TabberNeue] Failed to load data for ${ this.pageTitle }: ${ error }` );
}
}
}
/**
* Class representing TabberEvent functionality for handling tab events and animations.
*
* @class
*/
class TabberEvent {
/**
* 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 ( !TabberEvent.shouldShowAnimation() ) {
return;
}
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} tabberEl - The tabber element containing the header and tab list.
*/
static updateHeaderOverflow( tabberEl ) {
const header = tabberEl.querySelector( ':scope > .tabber__header' );
const tablist = header.querySelector( ':scope > .tabber__tabs' );
const { roundScrollLeft } = Util;
const tablistWidth = tablist.offsetWidth;
const tablistScrollWidth = tablist.scrollWidth;
const isScrollable = ( tablistScrollWidth > header.offsetWidth );
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 );
} );
}
/**
* Updates the tab indicator to visually indicate the active tab.
*
* @param {Element} tabberEl - The tabber element containing the tabs and indicator.
* @param {Element} activeTab - The currently active tab element.
*/
static updateIndicator( tabberEl, activeTab ) {
const indicator = tabberEl.querySelector( '.tabber__indicator' );
const tablist = tabberEl.querySelector( '.tabber__tabs' );
window.requestAnimationFrame( () => {
const width = Util.getElementSize( activeTab, 'width' );
indicator.style.width = width + 'px';
indicator.style.transform = 'translateX(' + ( activeTab.offsetLeft - Util.roundScrollLeft( tablist.scrollLeft ) ) + 'px)';
} );
}
static setActiveTabpanel( activeTabpanel ) {
const section = activeTabpanel.closest( '.tabber__section' );
if ( activeTabpanel.dataset.mwTabberLoadUrl ) {
const tabberTransclude = new TabberTransclude( activeTabpanel );
tabberTransclude.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 based on the provided tab panel ID.
* Updates the ARIA attributes for tab panels and tabs to reflect the active state.
* Also updates the tab indicator to visually indicate the active tab.
*
* @param {string} tabpanelId - The ID of the tab panel to set as active.
*/
static setActiveTab( tabpanelId ) {
const activeTabpanel = document.getElementById( tabpanelId );
const activeTab = document.getElementById( `tab-${ tabpanelId }` );
const tabberEl = activeTabpanel.closest( '.tabber' );
const tabpanels = tabberEl.querySelectorAll( ':scope > .tabber__section > .tabber__panel' );
const tabs = tabberEl.querySelectorAll( ':scope > .tabber__header > .tabber__tabs > .tabber__tab' );
[ ...tabpanels ].forEach( ( tabpanel ) => {
tabpanel.setAttribute( 'aria-hidden', 'true' );
if ( typeof resizeObserver !== 'undefined' && resizeObserver ) {
resizeObserver.unobserve( tabpanel );
}
} );
[ ...tabs ].forEach( ( tab ) => {
tab.setAttribute( 'aria-selected', 'false' );
} );
// Ensure `resizeObserver` is defined before using it
if ( typeof resizeObserver !== 'undefined' && resizeObserver ) {
resizeObserver.observe( activeTabpanel );
}
activeTabpanel.setAttribute( 'aria-hidden', 'false' );
activeTab.setAttribute( 'aria-selected', 'true' );
TabberEvent.updateIndicator( tabberEl, activeTab );
TabberEvent.setActiveTabpanel( activeTabpanel );
}
/**
* 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 TabberEvent 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 scrollOffset = type === 'prev' ? -tablist.offsetWidth / 2 : tablist.offsetWidth / 2;
TabberEvent.scrollTablist( scrollOffset, tablist );
}
/**
* Handles the click event on a tab element.
* If a tab element is clicked, it sets the tab panel as active and updates the URL hash
* without adding to browser history.
*
* @param {Event} e - The click event object.
*/
static handleClick( e ) {
const tab = e.target.closest( '.tabber__tab' );
if ( tab ) {
// Prevent default anchor actions
e.preventDefault();
const tabpanelId = tab.getAttribute( 'aria-controls' );
// Update the URL hash without adding to browser history
if ( config.updateLocationOnTabChange ) {
history.replaceState( null, '', window.location.pathname + window.location.search + '#' + tabpanelId );
}
TabberEvent.setActiveTab( tabpanelId );
return;
}
const isPointerDevice = window.matchMedia( '(hover: hover)' ).matches;
if ( isPointerDevice ) {
const prevButton = e.target.closest( '.tabber__header__prev' );
if ( prevButton ) {
TabberEvent.handleHeaderButton( prevButton, 'prev' );
return;
}
const nextButton = e.target.closest( '.tabber__header__next' );
if ( nextButton ) {
TabberEvent.handleHeaderButton( nextButton, 'next' );
return;
}
}
}
/**
* Checks if there are entries and the first entry has a target element
* that is an instance of Element.
* If true, calls the setActiveTabpanel method of the TabberEvent class
* with the activeTabpanel as the argument.
*
* @param {ResizeObserverEntry[]} entries
*/
static handleElementResize( entries ) {
if ( entries && entries.length > 0 ) {
const activeTabpanel = entries[ 0 ].target;
if ( activeTabpanel instanceof Element ) {
TabberEvent.setActiveTabpanel( activeTabpanel );
}
}
}
/**
* Sets up event listeners for tab elements.
* Attaches a click event listener to the body content element,
* delegating the click event to the tab elements.
* When a tab element is clicked, it triggers the handleClick method of the TabberEvent class.
*/
static attachEvents() {
const bodyContent = document.getElementById( 'mw-content-text' );
bodyContent.addEventListener( 'click', TabberEvent.handleClick );
if ( window.ResizeObserver ) {
resizeObserver = new ResizeObserver( TabberEvent.handleElementResize );
}
}
}
/**
* 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 tab elements for each tab panel in the tabber.
*
* It creates a document fragment to hold the tab elements, then iterates over each tab panel
* element in the tabber. For each tab panel, it calls the createTabElement method to create a
* corresponding tab element and appends it to the fragment. Finally, it adds the fragment
* to the tablist element, sets the necessary attributes for the tablist, and adds a
* CSS class for styling.
*/
createTabs() {
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' );
}
/**
* Creates the indicator element for the tabber.
*
* This method creates a div element to serve as the indicator for the active tab.
* It adds the 'tabber__indicator' CSS class to the indicator element and appends it to the
* header of the tabber.
*/
createIndicator() {
const indicator = document.createElement( 'div' );
indicator.classList.add( 'tabber__indicator' );
this.header.append( indicator );
}
/**
* Creates the header elements for the tabber.
*
* This method creates two buttons for navigating to the previous and next tabs,
* adds a tablist element. Finally, it appends all these elements to the header of the tabber.
*/
createHeader() {
const prevButton = document.createElement( 'div' );
prevButton.classList.add( 'tabber__header__prev' );
const nextButton = document.createElement( 'div' );
nextButton.classList.add( 'tabber__header__next' );
this.header.append( prevButton, this.tablist, nextButton );
}
/**
* Attach events to the tabber element.
* This method checks if the window has ResizeObserver support and
* creates an observer to update the header overflow.
* If ResizeObserver is supported,it creates an observer to
* call TabberEvent.updateHeaderOverflow method with a debounce of 250ms.
* The observer is then set to observe the tablist element of the tabber.
*/
attachEvents() {
this.tablist.addEventListener( 'scroll', () => {
window.requestAnimationFrame( () => {
const activeTab = this.tablist.querySelector( '[aria-selected="true"]' );
TabberEvent.toggleAnimation( false );
TabberEvent.updateHeaderOverflow( this.tabber );
TabberEvent.updateIndicator( this.tabber, activeTab );
// Disable animiation for a short time so that the indicator don't get animated
setTimeout( () => {
TabberEvent.toggleAnimation( true );
}, 100 );
} );
} );
if ( window.ResizeObserver ) {
const headerOverflowObserver = new ResizeObserver( mw.util.debounce( 250, () => {
TabberEvent.updateHeaderOverflow( this.tabber );
} ) );
headerOverflowObserver.observe( this.tablist );
}
}
/**
* Initializes the TabberBuilder by creating tabs, header, and indicator elements.
* Also updates the indicator using TabberEvent.
*/
init() {
this.createTabs();
this.createHeader();
this.createIndicator();
const firstTab = this.tablist.querySelector( '.tabber__tab' );
const firstTabId = firstTab.getAttribute( 'aria-controls' );
TabberEvent.setActiveTab( firstTabId );
TabberEvent.updateHeaderOverflow( this.tabber );
this.attachEvents();
}
}
/**
* Loads tabbers with the given elements using the provided configuration.
*
* @param {NodeList} tabberEls - The elements representing tabbers to be loaded.
* @return {void}
*/
function load( tabberEls ) {
mw.loader.load( 'ext.tabberNeue.icons' );
Hash.init();
tabberEls.forEach( ( tabberEl ) => {
const tabberBuilder = new TabberBuilder( tabberEl );
tabberBuilder.init();
} );
const style = document.getElementById( 'tabber-style' );
if ( style ) {
style.remove();
}
const urlHash = new mw.Uri( location.href ).fragment;
if ( Hash.exists( urlHash ) ) {
TabberEvent.setActiveTab( urlHash );
const activeTabpanel = document.getElementById( urlHash );
window.requestAnimationFrame( () => {
activeTabpanel.scrollIntoView( { behavior: 'auto', block: 'end', inline: 'nearest' } );
} );
}
TabberEvent.toggleAnimation( true );
TabberEvent.attachEvents();
}
/**
* 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();
} );
} );

View file

@ -39,6 +39,11 @@
/* fixes cross browser quarks */
min-block-size: fit-content;
button {
/* Remove all default button styles */
all: unset;
}
&__prev {
left: 0;
}
@ -58,7 +63,7 @@
border-radius: 4px;
cursor: pointer;
&:after {
&::after {
position: absolute;
top: 0;
bottom: 0;
@ -141,49 +146,56 @@
// Hide edit buttons for non-transclusion tabs since they don't work
/* stylelint-disable-next-line selector-class-pattern */
&:not( [ data-tabber-page-title ] ) .mw-editsection {
&:not( [ data-mw-tabber-page-title ] ) .mw-editsection {
display: none;
}
}
&__transclusion {
/*
* Animation based on dots indicators from Wikimedia
* T266028
*/
&--loading {
margin: 0.5em 0; // based on Vector
text-align: center;
white-space: nowrap;
/*
* Animation based on dots indicators from Wikimedia
* T266028
*/
.tabber__transclusion {
margin: 0.5em 0; // based on Vector
text-align: center;
white-space: nowrap;
.tabber__loading-indicator,
&:before,
&:after {
display: inline-block;
width: @width-bounce-dot;
height: @width-bounce-dot;
border-radius: 100%;
animation: bounce-delay 1600ms infinite ease-in-out both;
animation-delay: -160ms;
background-color: #36c;
content: '';
}
.tabber__loading-indicator,
&::before,
&::after {
display: inline-block;
width: @width-bounce-dot;
height: @width-bounce-dot;
border-radius: 100%;
animation: bounce-delay 1600ms infinite ease-in-out both;
animation-delay: -160ms;
background-color: #36c;
content: '';
}
&:before {
margin-right: @width-bounce-dot / 4;
animation-delay: -330ms;
}
&::before {
margin-right: @width-bounce-dot / 4;
animation-delay: -330ms;
}
&:after {
margin-left: @width-bounce-dot / 4;
animation-delay: 0ms;
&::after {
margin-left: @width-bounce-dot / 4;
animation-delay: 0ms;
}
}
}
}
}
// Animations
.tabber-animations-ready {
.tabber {
&__indicator {
transition: transform 250ms ease, width 250ms ease;
}
}
}
@keyframes bounce-delay {
0%,
50%,
@ -205,7 +217,7 @@
&__next {
pointer-events: none; // Disable arrow button
&:after {
&::after {
background-image: none; // Remove arrow icon
}
}

View file

@ -1,5 +1,9 @@
{
"extends": [
"wikimedia/jquery"
],
"globals": {
"ve": true
"OO": "readonly",
"ve": "readonly"
}
}

5402
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -9,8 +9,13 @@
"lint:i18n": "banana-checker --requireLowerCase=0 i18n/"
},
"devDependencies": {
"@wikimedia/codex": "^1.6.0",
"@wikimedia/types-wikimedia": "0.4.3",
"eslint-config-wikimedia": "0.27.0",
"eslint-plugin-no-jquery": "2.7.0",
"grunt-banana-checker": "0.13.0",
"oojs": "^7.0.1",
"oojs-ui": "^0.49.2",
"stylelint-config-idiomatic-order": "10.0.0",
"stylelint-config-wikimedia": "0.16.1"
}