From 1e1d3de9cd2f2832826efda2019048752b5dbc64 Mon Sep 17 00:00:00 2001 From: alistair3149 Date: Fri, 15 Nov 2024 19:17:08 -0500 Subject: [PATCH] feat: render all HTML server-side --- includes/Tabber.php | 15 +- .../ext.tabberNeue.codex.js | 4 +- .../ext.tabberNeue.init.less | 5 + modules/ext.tabberNeue/Transclude.js | 1 + modules/ext.tabberNeue/ext.tabberNeue.js | 229 ++---------------- modules/ext.tabberNeue/ext.tabberNeue.less | 20 +- modules/ve-tabberNeue/ve.ce.MWTabberNode.js | 37 +-- .../ve.ce.MWTabberTranscludeNode.js | 2 +- package-lock.json | 11 +- 9 files changed, 46 insertions(+), 278 deletions(-) diff --git a/includes/Tabber.php b/includes/Tabber.php index 19eeb1a..23b0390 100644 --- a/includes/Tabber.php +++ b/includes/Tabber.php @@ -108,8 +108,9 @@ class Tabber { } return "
" . - '
' . - // '
' . + '
' . + '' . + '
' . '
' . $tabpanels . '
'; } @@ -215,11 +216,13 @@ class Tabber { * @return string HTML */ private static function getTabHTML( array $tabData ): string { + $tabpanelId = "tabber-tabpanel-{$tabData['id']}"; return Html::rawElement( 'a', [ 'class' => 'tabber__tab', 'id' => "tabber-tab-{$tabData['id']}", - 'href' => "#tabber-tabpanel-{$tabData['id']}", + 'href' => "#$tabpanelId", 'role' => 'tab', + 'aria-controls' => $tabpanelId ], $tabData['label'] ); } @@ -239,8 +242,10 @@ class Tabber { } return Html::rawElement( 'article', [ 'class' => 'tabber__panel', - // 'id' => "tabber-tabpanel-{$tabData['id']}", - 'data-mw-tabber-title' => $tabData['label'], + 'id' => "tabber-tabpanel-{$tabData['id']}", + 'role' => 'tabpanel', + 'tabindex' => 0, + 'aria-labelledby' => "tabber-tab-{$tabData['id']}" ], $content ); } diff --git a/modules/ext.tabberNeue.codex/ext.tabberNeue.codex.js b/modules/ext.tabberNeue.codex/ext.tabberNeue.codex.js index a6467c9..9f3951b 100644 --- a/modules/ext.tabberNeue.codex/ext.tabberNeue.codex.js +++ b/modules/ext.tabberNeue.codex/ext.tabberNeue.codex.js @@ -43,14 +43,14 @@ function main() { tabbers.forEach( initApp ); } -mw.hook( 'wikipage.content' ).add( function () { +mw.hook( 'wikipage.content' ).add( () => { main(); } ); /* * Add hooks for Tabber when Visual Editor is used. */ -mw.loader.using( 'ext.visualEditor.desktopArticleTarget.init', function () { +mw.loader.using( 'ext.visualEditor.desktopArticleTarget.init', () => { // After saving edits mw.hook( 'postEdit.afterRemoval' ).add( () => { main(); diff --git a/modules/ext.tabberNeue.init/ext.tabberNeue.init.less b/modules/ext.tabberNeue.init/ext.tabberNeue.init.less index 18c424f..76ba762 100644 --- a/modules/ext.tabberNeue.init/ext.tabberNeue.init.less +++ b/modules/ext.tabberNeue.init/ext.tabberNeue.init.less @@ -1,6 +1,11 @@ .tabber { &__header { box-shadow: inset 0 -1px 0 0 var( --border-color-base, #a2a9b1 ); + + &__prev, + &__next { + display: none; + } } &__tabs { diff --git a/modules/ext.tabberNeue/Transclude.js b/modules/ext.tabberNeue/Transclude.js index ace0270..b8e95db 100644 --- a/modules/ext.tabberNeue/Transclude.js +++ b/modules/ext.tabberNeue/Transclude.js @@ -49,6 +49,7 @@ class Transclude { */ async fetchDataFromUrl() { try { + // eslint-disable-next-line n/no-unsupported-features/node-builtins 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 }` ); diff --git a/modules/ext.tabberNeue/ext.tabberNeue.js b/modules/ext.tabberNeue/ext.tabberNeue.js index 5a38a1d..bae88f8 100644 --- a/modules/ext.tabberNeue/ext.tabberNeue.js +++ b/modules/ext.tabberNeue/ext.tabberNeue.js @@ -87,28 +87,6 @@ class TabberAction { } ); } - /** - * 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; - - indicator.classList.add( 'tabber__indicator--visible' ); - tablist.classList.add( 'tabber__tabs--animate' ); - indicator.style.width = width + 'px'; - indicator.style.transform = `translateX(${ transformValue }px)`; - setTimeout( () => { - indicator.classList.remove( 'tabber__indicator--visible' ); - tablist.classList.remove( 'tabber__tabs--animate' ); - }, 250 ); - } - /** * Returns the tabpanel element based on the tab element * @@ -136,12 +114,13 @@ class TabberAction { transclude.loadPage(); } + const activeTabpanelHeight = Util.getElementSize( + activeTabpanel, + 'height' + ); + section.style.height = activeTabpanelHeight + 'px'; + window.requestAnimationFrame( () => { - const activeTabpanelHeight = Util.getElementSize( - activeTabpanel, - 'height' - ); - section.style.height = activeTabpanelHeight + 'px'; // Scroll to tab section.scrollLeft = activeTabpanel.offsetLeft; } ); @@ -155,7 +134,6 @@ class TabberAction { /** * 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. @@ -164,9 +142,6 @@ class TabberAction { return new Promise( ( resolve ) => { const activeTabpanel = TabberAction.getTabpanel( activeTab ); const tabberEl = activeTabpanel.closest( '.tabber' ); - const indicator = tabberEl.querySelector( - ':scope > .tabber__header > .tabber__indicator' - ); const currentActiveTab = tabberEl.querySelector( ':scope > .tabber__header > .tabber__tabs > .tabber__tab[aria-selected="true"]' ); let currentActiveTabpanel; @@ -204,12 +179,6 @@ class TabberAction { Util.setAttributes( activeTab, activeTabAttributes ); Util.setAttributes( activeTabpanel, activeTabpanelAttributes ); - - TabberAction.animateIndicator( - indicator, - activeTab, - activeTab.parentElement - ); TabberAction.setActiveTabpanel( activeTabpanel, currentActiveTabpanel ); resolve(); @@ -286,9 +255,6 @@ class TabberEvent { this.tabs = this.tablist.querySelectorAll( ':scope > .tabber__tab' ); this.activeTab = this.tablist.querySelector( '[aria-selected="true"]' ); this.activeTabpanel = TabberAction.getTabpanel( this.activeTab ); - this.indicator = this.tabber.querySelector( - ':scope > .tabber__header > .tabber__indicator' - ); this.tabFocus = 0; this.debouncedUpdateHeaderOverflow = mw.util.debounce( () => TabberAction.updateHeaderOverflow( this.tablist ), @@ -454,187 +420,31 @@ class TabberEvent { } /** - * Class responsible for creating tabs, headers, and indicators for a tabber element. + * Class responsible for initalizing 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' ); + this.tablist = tabber.querySelector( ':scope > .tabber__header > .tabber__tabs' ); } /** * 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 ) { + setTabsAttributes() { const tabAttributes = { - class: 'tabber__tab', - href: '#' + tabId, - id: 'tab-' + tabId, - role: 'tab', tabindex: '-1', - 'aria-selected': false, - 'aria-controls': tabId + 'aria-selected': false }; - - 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; + for ( const tab of this.tablist.children ) { + Util.setAttributes( tab, tabAttributes ); } - - 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 = { - id: tabId, - role: 'tabpanel', - tabindex: '-1', - 'aria-hidden': 'true', - 'aria-labelledby': `tab-${ 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 = Util.extractTextFromHtml( titleAttr ); - } else { - tabId = titleAttr; - } - - tabId = Hash.build( tabId, config.useLegacyTabIds ); - - 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 tabs attributes * Sets the active tab based on the URL hash, and updates the header overflow. * Attaches event listeners for tabber interaction. * @@ -642,19 +452,14 @@ class TabberBuilder { * @return {void} */ async init( urlHash ) { - // Create tabs, header, and indicator elements sequentially - await this.createTabs(); - await this.createHeader(); - await this.createIndicator(); - - const activeTab = this.tablist.querySelector( `#tab-${ CSS.escape( urlHash ) }` ) || this.tablist.firstElementChild; + const activeTab = this.tablist.querySelector( `#tabber-tab-${ CSS.escape( urlHash ) }` ) || this.tablist.firstElementChild; + this.setTabsAttributes(); await TabberAction.setActiveTab( activeTab ); TabberAction.updateHeaderOverflow( this.tablist ); // Start attaching event const tabberEvent = new TabberEvent( this.tabber, this.tablist ); tabberEvent.init(); - this.tabber.classList.remove( 'tabber--init' ); this.tabber.classList.add( 'tabber--live' ); } @@ -686,7 +491,7 @@ async function load( tabberEls ) { TabberAction.toggleAnimation( true ); window.addEventListener( 'hashchange', ( event ) => { const newHash = window.location.hash.slice( 1 ); - const tab = document.getElementById( `tab-${ CSS.escape( newHash ) }` ); + const tab = document.getElementById( `tabber-tab-${ CSS.escape( newHash ) }` ); if ( tab ) { event.preventDefault(); tab.click(); @@ -702,7 +507,7 @@ async function load( tabberEls ) { * each tabber element. */ function main() { - const tabberEls = document.querySelectorAll( '.tabber:not(.tabber--live)' ); + const tabberEls = document.querySelectorAll( '.tabber--init' ); if ( tabberEls.length === 0 ) { return; diff --git a/modules/ext.tabberNeue/ext.tabberNeue.less b/modules/ext.tabberNeue/ext.tabberNeue.less index 4f90da9..63e3bd4 100644 --- a/modules/ext.tabberNeue/ext.tabberNeue.less +++ b/modules/ext.tabberNeue/ext.tabberNeue.less @@ -9,12 +9,6 @@ --tabber-background-color-button-quiet--active: var( --background-color-button-quiet--active, rgba( 0, 24, 73, 0.082 ) ); // @background-color-button-quiet--active --tabber-border-color-base: var( --border-color-base, #a2a9b1 ); // @border-color-base --tabber-height-indicator: 2px; - position: relative; - display: flex; - - /* establish primary containing box */ - overflow: hidden; - flex-direction: column; &__header { position: relative; @@ -43,10 +37,10 @@ & &__prev, & &__next { position: absolute; + display: none; // Required as all:unset also unset display:none z-index: 1; top: 0; bottom: 0; - display: none; width: 20px; border-radius: 4px; cursor: pointer; @@ -83,18 +77,6 @@ } } - &__indicator { - display: none; - margin-top: ~'calc( var( --tabber-height-indicator ) * -1 )'; - background: var( --tabber-color-progressive ); - block-size: var( --tabber-height-indicator ); - inline-size: 0; - - &--visible { - display: block; - } - } - &__tab { &[ aria-selected='true' ] { box-shadow: 0 -2px 0 var( --color-progressive, #36c ) inset; diff --git a/modules/ve-tabberNeue/ve.ce.MWTabberNode.js b/modules/ve-tabberNeue/ve.ce.MWTabberNode.js index fe1e8f9..c5a6292 100644 --- a/modules/ve-tabberNeue/ve.ce.MWTabberNode.js +++ b/modules/ve-tabberNeue/ve.ce.MWTabberNode.js @@ -64,49 +64,18 @@ ve.ce.MWTabberNode.prototype.onSetup = function () { */ ve.ce.MWTabberNode.prototype.renderHeader = function ( tabber ) { const nestedTabbers = tabber.querySelectorAll( '.tabber__panel:first-child .tabber' ); - const renderSingleHeader = function ( element ) { - const - tabPanels = element.querySelectorAll( ':scope > .tabber__section > .tabber__panel' ), - header = element.querySelector( ':scope > .tabber__header' ), - tabList = document.createElement( 'nav' ), - indicator = document.createElement( 'div' ), - fragment = new DocumentFragment(); - - Array.prototype.forEach.call( tabPanels, function ( tabPanel, index ) { - const tab = document.createElement( 'a' ); - - tab.innerText = tabPanel.getAttribute( 'data-mw-tabber-title' ); - tab.classList.add( 'tabber__tab' ); - - // Make first tab active - if ( index === 0 ) { - tab.setAttribute( 'aria-selected', true ); - } - - fragment.append( tab ); - } ); - - tabList.append( fragment ); - - tabList.classList.add( 'tabber__tabs' ); - indicator.classList.add( 'tabber__indicator' ); - - header.append( tabList, indicator ); - - indicator.style.width = tabList.firstElementChild.offsetWidth + 'px'; - - element.classList.add( 'tabber--live' ); + const firstTab = element.querySelector( ':scope > .tabber__header > .tabber__tabs > .tabber__tab' ); + firstTab.setAttribute( 'aria-selected', true ); }; if ( nestedTabbers.length > 0 ) { - Array.prototype.forEach.call( nestedTabbers, function ( nestedTabber ) { + Array.prototype.forEach.call( nestedTabbers, ( nestedTabber ) => { renderSingleHeader( nestedTabber ); } ); } renderSingleHeader( tabber ); - lastHeader = tabber.firstElementChild; }; diff --git a/modules/ve-tabberNeue/ve.ce.MWTabberTranscludeNode.js b/modules/ve-tabberNeue/ve.ce.MWTabberTranscludeNode.js index 4ad017b..46c9046 100644 --- a/modules/ve-tabberNeue/ve.ce.MWTabberTranscludeNode.js +++ b/modules/ve-tabberNeue/ve.ce.MWTabberTranscludeNode.js @@ -70,7 +70,7 @@ ve.ce.MWTabberTranscludeNode.prototype.renderHeader = function ( tabber ) { indicator = document.createElement( 'div' ), fragment = new DocumentFragment(); - Array.prototype.forEach.call( tabPanels, function ( tabPanel, index ) { + Array.prototype.forEach.call( tabPanels, ( tabPanel, index ) => { const tab = document.createElement( 'a' ); tab.innerText = tabPanel.getAttribute( 'data-mw-tabber-title' ); diff --git a/package-lock.json b/package-lock.json index dfc3556..465c347 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "mediawiki-extensions-TabberNeue", + "name": "TabberNeue", "lockfileVersion": 3, "requires": true, "packages": { @@ -1003,9 +1003,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001621", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001621.tgz", - "integrity": "sha512-+NLXZiviFFKX0fk8Piwv3PfLPGtRqJeq2TiNoUff/qB5KJgwecJTvCXDpmlyP/eCI/GUEmp/h/y5j0yckiiZrA==", + "version": "1.0.30001680", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", + "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", "dev": true, "funding": [ { @@ -1020,7 +1020,8 @@ "type": "github", "url": "https://github.com/sponsors/ai" } - ] + ], + "license": "CC-BY-4.0" }, "node_modules/chalk": { "version": "4.1.2",