diff --git a/includes/Tabber.php b/includes/Tabber.php index 543c7b3..c6abcb6 100644 --- a/includes/Tabber.php +++ b/includes/Tabber.php @@ -14,20 +14,25 @@ declare( strict_types=1 ); namespace MediaWiki\Extension\TabberNeue; +use Html; use JsonException; use MediaWiki\MediaWikiServices; use Parser; use PPFrame; +use Sanitizer; class Tabber { - /** - * Flag that checks if this is a nested tabber - * @var bool - */ + + /** @var int */ + private static $count = 0; + + /** @var bool Flag that checks if this is a nested tabber */ private static $isNested = false; + /** @var bool */ private static $useCodex = false; + /** @var bool */ private static $parseTabName = false; /** @@ -42,8 +47,11 @@ class Tabber { */ public static function parserHook( ?string $input, array $args, Parser $parser, PPFrame $frame ) { $config = MediaWikiServices::getInstance()->getMainConfig(); + $parserOutput = $parser->getOutput(); + self::$parseTabName = $config->get( 'TabberNeueParseTabName' ); self::$useCodex = $config->get( 'TabberNeueUseCodex' ); + self::$count = count( $parserOutput->getExtensionData( 'tabber-count' ) ?? [] ); $html = self::render( $input ?? '', $parser, $frame ); @@ -51,6 +59,8 @@ class Tabber { return ''; } + $parserOutput->appendExtensionData( 'tabber-count', self::$count++ ); + if ( self::$useCodex === true ) { $parser->getOutput()->addModules( [ 'ext.tabberNeue.codex' ] ); } else { @@ -73,34 +83,36 @@ class Tabber { */ public static function render( string $input, Parser $parser, PPFrame $frame ): string { $arr = explode( '|-|', $input ); - $htmlTabs = ''; + $tabs = ''; + $tabpanels = ''; foreach ( $arr as $tab ) { $tabData = self::getTabData( $tab, $parser, $frame ); - if ( $tabData['label'] === '' ) { + if ( $tabData === [] ) { continue; } if ( self::$useCodex && self::$isNested ) { - $htmlTabs .= self::getCodexNestedTabJSON( $tabData ); + $tabpanels .= self::getCodexNestedTabJSON( $tabData ); continue; } - $htmlTabs .= self::buildTabpanel( $tabData ); + $tabs .= self::getTabHTML( $tabData ); + $tabpanels .= self::getTabpanelHTML( $tabData ); } if ( self::$useCodex && self::$isNested ) { - $tab = rtrim( implode( '},', explode( '}', $htmlTabs ) ), ',' ); - $tab = strip_tags( html_entity_decode( $tab ) ); - $tab = str_replace( ',,', ',', $tab ); - $tab = str_replace( ',]', ']', $tab ); - - return sprintf( '[%s]', $tab ); + $tabpanels = rtrim( implode( '},', explode( '}', $tabpanels ) ), ',' ); + $tabpanels = strip_tags( html_entity_decode( $tab ) ); + $tabpanels = str_replace( ',,', ',', $tabpanels ); + $tabpanels = str_replace( ',]', ']', $tabpanels ); + return sprintf( '[%s]', $tabpanels ); } - return '
' . + return '
' . '
' . - '
' . $htmlTabs . '
'; + // '
' . + '
' . $tabpanels . '
'; } /** @@ -125,7 +137,6 @@ class Tabber { // Might contains HTML $label = $parser->recursiveTagParseFully( $label ); $label = $parser->stripOuterParagraph( $label ); - $label = htmlentities( $label ); } return $label; } @@ -144,21 +155,26 @@ class Tabber { if ( $content === '' ) { return ''; } - // Fix #151, some wikitext magic - $content = "\n" . $content . "\n"; + if ( !self::$useCodex ) { - $content = $parser->recursiveTagParse( $content, $frame ); - } else { - // A nested tabber which should return json in codex - if ( strpos( $content, '{{#tag:tabber' ) !== false ) { - self::$isNested = true; - $content = $parser->recursiveTagParse( $content, $frame ); - self::$isNested = false; - // The outermost tabber that must be parsed fully in codex for correct json - } else { - $content = $parser->recursiveTagParseFully( $content, $frame ); + $wikitextListMarkers = [ '*', '#', ';', ':' ]; + $isWikitextList = in_array( substr( $content, 0, 1 ), $wikitextListMarkers ); + if ( $isWikitextList ) { + // Fix #151, some wikitext magic + // Seems like there is no way to get rid of the mw-empty-elt paragraphs sadly + $content = "\n$content\n"; } + return $parser->recursiveTagParse( $content, $frame ); } + + // The outermost tabber that must be parsed fully in codex for correct json + if ( strpos( $content, '{{#tag:tabber' ) === false ) { + return $parser->recursiveTagParseFully( $content, $frame ); + } + + self::$isNested = true; + $content = $parser->recursiveTagParse( $content, $frame ); + self::$isNested = false; return $content; } @@ -172,10 +188,7 @@ class Tabber { * @return array */ private static function getTabData( string $tab, Parser $parser, PPFrame $frame ): array { - $data = [ - 'label' => '', - 'content' => '' - ]; + $data = []; if ( empty( trim( $tab ) ) ) { return $data; } @@ -189,28 +202,47 @@ class Tabber { } $data['content'] = self::getTabContent( $content, $parser, $frame ); + + $id = Sanitizer::escapeIdForAttribute( htmlspecialchars( $data['label'] ) ) . '-' . self::$count; + $data['id'] = $id; return $data; } /** - * Build individual tabpanel. + * Get the HTML for a tab. * * @param array $tabData Tab data * * @return string HTML */ - private static function buildTabpanel( array $tabData ): string { - $label = $tabData['label']; - $content = $tabData['content']; + private static function getTabHTML( array $tabData ): string { + return Html::rawElement( 'a', [ + 'class' => 'tabber__tab', + 'id' => "tabber-tab-{$tabData['id']}", + 'href' => "#tabber-tabpanel-{$tabData['id']}", + 'role' => 'tab', + ], $tabData['label'] ); + } + /** + * Get the HTML for a tabpanel. + * + * @param array $tabData Tab data + * + * @return string HTML + */ + private static function getTabpanelHTML( array $tabData ): string { + $content = $tabData['content']; $isContentHTML = strpos( $content, '<' ) === 0; if ( $content && !$isContentHTML ) { // If $content does not have any HTML element (i.e. just a text node), wrap it in

- $content = '

' . $content . '

'; + $content = Html::rawElement( 'p', [], $content ); } - - return '
' . $content . "
"; + return Html::rawElement( 'article', [ + 'class' => 'tabber__panel', + // 'id' => "tabber-tabpanel-{$tabData['id']}", + 'data-mw-tabber-title' => $tabData['label'], + ], $content ); } /** diff --git a/includes/TabberTransclude.php b/includes/TabberTransclude.php index d1be364..50a1c18 100644 --- a/includes/TabberTransclude.php +++ b/includes/TabberTransclude.php @@ -39,7 +39,7 @@ class TabberTransclude { } $parser->getOutput()->addModuleStyles( [ 'ext.tabberNeue.init.styles' ] ); - $parser->getOutput()->addModules( [ 'ext.tabberNeue' ] ); + //$parser->getOutput()->addModules( [ 'ext.tabberNeue' ] ); $parser->addTrackingCategory( 'tabberneue-tabbertransclude-category' ); return $html; @@ -60,7 +60,7 @@ class TabberTransclude { $htmlTabs = ''; foreach ( $arr as $tab ) { $tabData = self::getTabData( $tab ); - if ( $tabData['label'] === '' ) { + if ( $tabData === [] ) { continue; } try { @@ -84,10 +84,7 @@ class TabberTransclude { * @return array */ private static function getTabData( string $tab ): array { - $data = [ - 'label' => '', - 'content' => '' - ]; + $data = []; if ( empty( trim( $tab ) ) ) { return $data; } diff --git a/modules/ext.tabberNeue.init/ext.tabberNeue.init.less b/modules/ext.tabberNeue.init/ext.tabberNeue.init.less index 5b84b6f..18c424f 100644 --- a/modules/ext.tabberNeue.init/ext.tabberNeue.init.less +++ b/modules/ext.tabberNeue.init/ext.tabberNeue.init.less @@ -1,72 +1,92 @@ -/** - * Critial rendering styles - * - * Since ext.tabberNeue is loaded a while after page load, - * inline styles are needed to create an inital state and - * avoid potential layout shifts. This should be kept as - * small as possible. - */ +.tabber { + &__header { + box-shadow: inset 0 -1px 0 0 var( --border-color-base, #a2a9b1 ); + } -/* stylelint-disable selector-class-pattern */ + &__tabs { + display: flex; + overflow: auto hidden; + } -/* Only apply skeleton UI when Tabber will be loaded */ -.client-js { - .tabber:not( .tabber--live ) { - .tabber__header { - height: 2.6em; - box-shadow: inset 0 -1px 0 0; - opacity: 0.1; + &__tab { + padding: 0.5em 0.75em; + color: var( --color-base, #202122 ); + font-weight: 700; + white-space: nowrap; - &::after { - position: absolute; - width: 16ch; - height: 0.5em; - border-radius: 40px; - margin-top: 1em; - margin-left: 0.75em; - animation-duration: 10s; - animation-fill-mode: forwards; - animation-iteration-count: infinite; - animation-name: skeletonload; - animation-timing-function: linear; - background: #000; - background: linear-gradient( to right, #202122 8%, #54595d 18%, #202122 33% ); - /* Use double quote in PHP */ - content: ''; + &, + &:visited { + color: var( --color-base, #202122 ); + } + + &:hover { + @media ( hover: hover ) { + color: var( --color-progressive--hover, #447ff5 ); + box-shadow: inset 0 -2px 0 0 var( --box-shadow-color-progressive-selected--hover, #447ff5 ); } } - /** - * Avoid layout shift by assigning the grid property early on - * Because display:block does not take into account of bottom margin of the content - */ - .tabber__section { - display: grid; + &:active { + @media ( hover: hover ) { + color: var( --color-progressive--active, #2a4b8d ); + } } - /** - * Hide all other panels - * All panels are stacked vertically initially - * then panels are stacked horizontally after Tabber is loaded - * Causing lots of layout shift - */ - .tabber__panel:not( :first-child ) { + &, + &:hover, + &:active, + &:focus { + text-decoration: none; + } + } + + &__section { + display: grid; + overflow: hidden; + block-size: 100%; + grid-auto-columns: 100%; + grid-auto-flow: column; + scroll-snap-type: x mandatory; + } + + &__panel { + height: max-content; + overflow-x: auto; + scroll-snap-align: start; + + // Hide edit buttons for non-transclusion tabs since they don't work + /* stylelint-disable-next-line selector-class-pattern */ + &:not( [ data-mw-tabber-page-title ] ) .mw-editsection { display: none; } } +} - /* Hide no script message */ - .tabber__noscript { - display: none; +// Set tabber height to the height of first tabpanel by +// setting subsequent tabpanels to have 0 height +.client-js { + .tabber { + &__tabs { + scrollbar-width: none; + + &::-webkit-scrollbar { + width: 0; + height: 0; + } + } + + &--init { + .tabber__panel ~ .tabber__panel { + height: 0; + } + } } } -@keyframes skeletonload { - 0% { - background-position: 0 0; +// Basic nojs support +.client-nojs { + .tabber__panel { + scroll-padding-top: 3rem; + height: auto; } - - 100% { - background-position: 100em 0; - } -} +} \ No newline at end of file diff --git a/modules/ext.tabberNeue/ext.tabberNeue.js b/modules/ext.tabberNeue/ext.tabberNeue.js index 0de7b22..5a38a1d 100644 --- a/modules/ext.tabberNeue/ext.tabberNeue.js +++ b/modules/ext.tabberNeue/ext.tabberNeue.js @@ -655,6 +655,7 @@ class TabberBuilder { const tabberEvent = new TabberEvent( this.tabber, this.tablist ); tabberEvent.init(); + this.tabber.classList.remove( 'tabber--init' ); this.tabber.classList.add( 'tabber--live' ); } } diff --git a/modules/ext.tabberNeue/ext.tabberNeue.less b/modules/ext.tabberNeue/ext.tabberNeue.less index 243ef34..4f90da9 100644 --- a/modules/ext.tabberNeue/ext.tabberNeue.less +++ b/modules/ext.tabberNeue/ext.tabberNeue.less @@ -16,26 +16,6 @@ overflow: hidden; flex-direction: column; - &__tabs { - display: flex; - overflow: auto hidden; - scrollbar-width: none; - - &::-webkit-scrollbar { - width: 0; - height: 0; - } - } - - &__section { - display: grid; - overflow: hidden; - block-size: 100%; - grid-auto-columns: 100%; - grid-auto-flow: column; - scroll-snap-type: x mandatory; - } - &__header { position: relative; display: flex; @@ -103,16 +83,6 @@ } } - &__header, - &__section { - scrollbar-width: none; - - &::-webkit-scrollbar { - width: 0; - height: 0; - } - } - &__indicator { display: none; margin-top: ~'calc( var( --tabber-height-indicator ) * -1 )'; @@ -126,49 +96,17 @@ } &__tab { - padding: 0.5em 0.75em; - color: var( --tabber-color ); - font-weight: bold; - text-decoration: none; - white-space: nowrap; - - &:visited { - color: var( --tabber-color ); - } - - &:hover, - &:active, - &:focus { - text-decoration: none; - } - &[ aria-selected='true' ] { - box-shadow: 0 -2px 0 var( --tabber-color-progressive ) inset; + box-shadow: 0 -2px 0 var( --color-progressive, #36c ) inset; } &[ aria-selected='true' ], &[ aria-selected='true' ]:visited { - color: var( --tabber-color-progressive ); - } - } - - &__tabs--animate { - .tabber__tab[ aria-selected='true' ] { - box-shadow: none; + color: var( --color-progressive, #36c ); } } &__panel { - height: max-content; - overflow-x: auto; - scroll-snap-align: start; - - // Hide edit buttons for non-transclusion tabs since they don't work - /* stylelint-disable-next-line selector-class-pattern */ - &:not( [ data-mw-tabber-page-title ] ) .mw-editsection { - display: none; - } - &--loading { .tabber__transclusion { opacity: 0.1; @@ -213,10 +151,6 @@ scroll-behavior: smooth; } - &__indicator { - transition: transform 250ms ease, width 250ms ease; - } - &__section, &__tabs { @media ( min-width: 720px ) { @@ -244,16 +178,6 @@ @media ( hover: hover ) { .tabber { - &__tab { - &:hover { - color: var( --tabber-color-progressive--hover ); - } - - &:active { - color: var( --tabber-color-progressive--active ); - } - } - &__header { &__prev, &__next {