diff --git a/javascript/Main.js b/javascript/Main.js
index 5f5c0853d8d..e8f8ab7b52f 100644
--- a/javascript/Main.js
+++ b/javascript/Main.js
@@ -28,6 +28,7 @@ const jsModules = [
'Selectall',
'Slider',
'SwitchButtons',
+ 'Table2',
'Tabs'
];
diff --git a/javascript/commons/Table2.js b/javascript/commons/Table2.js
new file mode 100644
index 00000000000..baaded6e323
--- /dev/null
+++ b/javascript/commons/Table2.js
@@ -0,0 +1,134 @@
+/*******************************************************************************
+ * Description: Handles dynamic striping for Table2 widgets,
+ * applying odd/even classes with rowspan grouping support.
+ ******************************************************************************/
+
+const TABLE2_CONFIG = {
+ SELECTORS: {
+ TABLE: '.table2 .table2__table',
+ BODY_ROW: 'tbody tr.table2__row--body',
+ ALL_ROWS: 'tbody tr'
+ },
+ CLASSES: {
+ EVEN: 'table2__row--even',
+ HEAD: 'table2__row--head'
+ }
+};
+
+class Table2Striper {
+ constructor( table ) {
+ this.table = table;
+ this.isSortable = table.classList.contains( 'sortable' );
+ this.shouldStripe = table.getAttribute( 'data-striped' ) !== 'false';
+ this.groupCounter = 0;
+ }
+
+ init() {
+ if ( this.shouldStripe ) {
+ this.restripe();
+
+ if ( this.isSortable ) {
+ this.setupSortListener();
+ }
+ }
+ this.setupHoverListeners();
+ }
+
+ setupSortListener() {
+ mw.loader.using( 'jquery.tablesorter' ).then( () => {
+ $( this.table ).on( 'sortEnd.tablesorter', () => this.restripe() );
+ } );
+ }
+
+ restripe() {
+ const rows = this.table.querySelectorAll( TABLE2_CONFIG.SELECTORS.ALL_ROWS );
+ let isEven = true;
+ let groupRemaining = 0;
+ this.groupCounter = 0;
+
+ rows.forEach( ( row ) => {
+ const isBodyRow = row.classList.contains( 'table2__row--body' );
+ const isSubheader = row.classList.contains( TABLE2_CONFIG.CLASSES.HEAD );
+
+ if ( isSubheader ) {
+ isEven = true;
+ groupRemaining = 0;
+ row.removeAttribute( 'data-group-id' );
+ return;
+ }
+
+ if ( !isBodyRow || row.style.display === 'none' ) {
+ return;
+ }
+
+ if ( groupRemaining === 0 ) {
+ isEven = !isEven;
+ this.groupCounter++;
+ }
+
+ const rowspanCount = parseInt( row.dataset.rowspanCount, 10 ) || 1;
+ groupRemaining = Math.max( groupRemaining, rowspanCount );
+
+ row.classList.toggle( TABLE2_CONFIG.CLASSES.EVEN, isEven );
+ row.setAttribute( 'data-group-id', this.groupCounter );
+
+ groupRemaining--;
+ } );
+ }
+
+ setupHoverListeners() {
+ const bodyRows = this.table.querySelectorAll( TABLE2_CONFIG.SELECTORS.BODY_ROW );
+
+ bodyRows.forEach( ( row ) => {
+ row.addEventListener( 'mouseenter', ( e ) => this.onRowHoverEnter( e ) );
+ row.addEventListener( 'mouseleave', ( e ) => this.onRowHoverLeave( e ) );
+ } );
+ }
+
+ onRowHoverEnter( event ) {
+ const row = event.target.closest( TABLE2_CONFIG.SELECTORS.BODY_ROW );
+ if ( !row ) {
+ return;
+ }
+
+ const groupId = row.getAttribute( 'data-group-id' );
+ if ( !groupId ) {
+ return;
+ }
+
+ const groupRows = this.table.querySelectorAll(
+ `${ TABLE2_CONFIG.SELECTORS.BODY_ROW }[data-group-id="${ groupId }"]`
+ );
+ groupRows.forEach( ( r ) => r.classList.add( 'table2__row--group-hover' ) );
+ }
+
+ onRowHoverLeave( event ) {
+ const row = event.target.closest( TABLE2_CONFIG.SELECTORS.BODY_ROW );
+ if ( !row ) {
+ return;
+ }
+
+ const groupId = row.getAttribute( 'data-group-id' );
+ if ( !groupId ) {
+ return;
+ }
+
+ const groupRows = this.table.querySelectorAll(
+ `${ TABLE2_CONFIG.SELECTORS.BODY_ROW }[data-group-id="${ groupId }"]`
+ );
+ groupRows.forEach( ( r ) => r.classList.remove( 'table2__row--group-hover' ) );
+ }
+}
+
+class Table2Module {
+ init() {
+ const tables = document.querySelectorAll( TABLE2_CONFIG.SELECTORS.TABLE );
+
+ tables.forEach( ( table ) => {
+ new Table2Striper( table ).init();
+ } );
+ }
+}
+
+liquipedia.table2 = new Table2Module();
+liquipedia.core.modules.push( 'table2' );
diff --git a/javascript/tests/Table2.test.js b/javascript/tests/Table2.test.js
new file mode 100644
index 00000000000..893adb02db2
--- /dev/null
+++ b/javascript/tests/Table2.test.js
@@ -0,0 +1,98 @@
+/**
+ * @jest-environment jsdom
+ */
+/* global jest */
+
+const { test, expect, beforeAll, describe } = require( '@jest/globals' );
+
+describe( 'Table2 module', () => {
+ beforeAll( () => {
+ globalThis.liquipedia = {
+ core: {
+ modules: []
+ }
+ };
+
+ globalThis.mw = {
+ loader: {
+ using: jest.fn( () => Promise.resolve() )
+ }
+ };
+
+ require( '../commons/Table2.js' );
+ } );
+
+ test( 'should register itself as a module', () => {
+ expect( globalThis.liquipedia.core.modules ).toContain( 'table2' );
+ } );
+
+ test( 'should initialize table2 instance', () => {
+ expect( globalThis.liquipedia.table2 ).toBeTruthy();
+ } );
+
+ test( 'should have init method', () => {
+ expect( typeof globalThis.liquipedia.table2.init ).toBe( 'function' );
+ } );
+
+ test( 'should assign data-group-id to body rows during striping', () => {
+ document.body.innerHTML = `
+
+ `;
+
+ globalThis.liquipedia.table2.init();
+
+ const rows = document.querySelectorAll( '.table2 .table2__table tbody tr.table2__row--body' );
+ expect( rows.length ).toBe( 3 );
+
+ rows.forEach( ( row ) => {
+ expect( row.getAttribute( 'data-group-id' ) ).toBeTruthy();
+ } );
+ } );
+
+ test( 'should apply even class to alternating rows', () => {
+ document.body.innerHTML = `
+
+ `;
+
+ globalThis.liquipedia.table2.init();
+
+ const rows = document.querySelectorAll( '.table2 .table2__table tbody tr.table2__row--body' );
+
+ expect( rows[ 0 ].classList.contains( 'table2__row--even' ) ).toBe( false );
+ expect( rows[ 1 ].classList.contains( 'table2__row--even' ) ).toBe( true );
+ expect( rows[ 2 ].classList.contains( 'table2__row--even' ) ).toBe( false );
+ expect( rows[ 3 ].classList.contains( 'table2__row--even' ) ).toBe( true );
+ } );
+
+ test( 'should ignore standalone tables without .table2 wrapper', () => {
+ document.body.innerHTML = `
+
+
+ | Standalone table row |
+
+
+ `;
+
+ globalThis.liquipedia.table2.init();
+
+ const row = document.querySelector( '.table2__table tbody tr' );
+ expect( row.getAttribute( 'data-group-id' ) ).toBeNull();
+ } );
+} );
diff --git a/lua/wikis/commons/Widget/Contexts/Table2.lua b/lua/wikis/commons/Widget/Contexts/Table2.lua
index f4a63f1535b..22489f96f6f 100644
--- a/lua/wikis/commons/Widget/Contexts/Table2.lua
+++ b/lua/wikis/commons/Widget/Contexts/Table2.lua
@@ -11,7 +11,6 @@ local Class = Lua.import('Module:Class')
local Context = Lua.import('Module:Widget/Context')
return {
- BodyStripe = Class.new(Context),
ColumnContext = Class.new(Context),
HeaderRowKind = Class.new(Context),
Section = Class.new(Context),
diff --git a/lua/wikis/commons/Widget/Table2/Row.lua b/lua/wikis/commons/Widget/Table2/Row.lua
index d3521447da8..094269a92df 100644
--- a/lua/wikis/commons/Widget/Table2/Row.lua
+++ b/lua/wikis/commons/Widget/Table2/Row.lua
@@ -32,12 +32,25 @@ local HtmlWidgets = Lua.import('Module:Widget/Html/All')
---@field props Table2RowProps
local Table2Row = Class.new(Widget)
+---@param rowChildren (Widget|Html|string|number|nil)[]
+---@return integer|nil
+local function getMaxRowspan(rowChildren)
+ local maxRowspan = Array.reduce(rowChildren, function(max, child)
+ if Class.instanceOf(child, Table2Cell) or Class.instanceOf(child, Table2CellHeader) then
+ local cellChild = child --[[@as Table2Cell|Table2CellHeader]]
+ local rowspan = MathUtil.toInteger(cellChild.props.rowspan) or 1
+ return math.max(max, math.max(rowspan, 1))
+ end
+ return max
+ end, 1)
+ return maxRowspan > 1 and maxRowspan or nil
+end
+
---@return Widget
function Table2Row:render()
local props = self.props
local section = props.section or self:useContext(Table2Contexts.Section)
local headerRowKind = self:useContext(Table2Contexts.HeaderRowKind)
- local bodyStripe = self:useContext(Table2Contexts.BodyStripe)
local sectionClass = 'table2__row--body'
if section == 'head' or section == 'subhead' then
@@ -53,15 +66,6 @@ function Table2Row:render()
end
end
- local stripeClass
- if section == 'body' then
- if bodyStripe == 'odd' then
- stripeClass = 'table2__row--odd'
- elseif bodyStripe == 'even' then
- stripeClass = 'table2__row--even'
- end
- end
-
local highlightClass
if section == 'body' and Logic.readBool(props.highlighted) then
highlightClass = 'table2__row--highlighted'
@@ -111,10 +115,18 @@ function Table2Row:render()
end)
end
+ local attributes = props.attributes or {}
+ if section == 'body' then
+ local maxRowspan = getMaxRowspan(children)
+ if maxRowspan then
+ attributes['data-rowspan-count'] = maxRowspan
+ end
+ end
+
return HtmlWidgets.Tr{
- classes = WidgetUtil.collect(sectionClass, kindClass, stripeClass, highlightClass, props.classes),
+ classes = WidgetUtil.collect(sectionClass, kindClass, highlightClass, props.classes),
css = props.css,
- attributes = props.attributes,
+ attributes = attributes,
children = trChildren,
}
end
diff --git a/lua/wikis/commons/Widget/Table2/Table.lua b/lua/wikis/commons/Widget/Table2/Table.lua
index d05bdce3e26..8178581cfc0 100644
--- a/lua/wikis/commons/Widget/Table2/Table.lua
+++ b/lua/wikis/commons/Widget/Table2/Table.lua
@@ -95,15 +95,13 @@ function Table2:render()
}}
end
- if Logic.readBool(props.striped) then
- tableChildren = {Table2Contexts.BodyStripe{
- value = true,
- children = tableChildren,
- }}
+ local tableAttributes = props.tableAttributes or {}
+ if not Logic.readBool(props.striped) then
+ tableAttributes['data-striped'] = 'false'
end
local tableNode = HtmlWidgets.Table{
- attributes = props.tableAttributes,
+ attributes = tableAttributes,
classes = tableClasses,
children = tableChildren,
}
diff --git a/lua/wikis/commons/Widget/Table2/TableBody.lua b/lua/wikis/commons/Widget/Table2/TableBody.lua
index f1ff55f8c23..f454094c624 100644
--- a/lua/wikis/commons/Widget/Table2/TableBody.lua
+++ b/lua/wikis/commons/Widget/Table2/TableBody.lua
@@ -7,15 +7,10 @@
local Lua = require('Module:Lua')
-local Array = Lua.import('Module:Array')
local Class = Lua.import('Module:Class')
-local MathUtil = Lua.import('Module:MathUtil')
local Widget = Lua.import('Module:Widget')
local Table2Contexts = Lua.import('Module:Widget/Contexts/Table2')
-local Table2Cell = Lua.import('Module:Widget/Table2/Cell')
-local Table2CellHeader = Lua.import('Module:Widget/Table2/CellHeader')
-local Table2Row = Lua.import('Module:Widget/Table2/Row')
---@class Table2BodyProps
---@field children Renderable[]?
@@ -27,70 +22,9 @@ local Table2Body = Class.new(Widget)
---@return Widget
function Table2Body:render()
- local props = self.props
- local children = props.children or {}
-
- local stripeEnabled = self:useContext(Table2Contexts.BodyStripe)
- if stripeEnabled == nil then
- return Table2Contexts.Section{
- value = 'body',
- children = children,
- }
- end
-
- local stripedChildren = {}
- local stripe = 'even'
- local groupRemaining = 0
-
- local function toggleStripe()
- stripe = stripe == 'even' and 'odd' or 'even'
- end
-
- local function getRowMaxRowspan(row)
- if row and row._cachedMaxRowspan then
- return row._cachedMaxRowspan
- end
-
- local rowChildren = (row and row.props and row.props.children) or {}
- local maxRowspan = 1
- Array.forEach(rowChildren, function(child)
- if Class.instanceOf(child, Table2Cell) or Class.instanceOf(child, Table2CellHeader) then
- local rowspan = MathUtil.toInteger(child.props.rowspan) or 1
- rowspan = math.max(rowspan, 1)
- maxRowspan = math.max(maxRowspan, rowspan)
- end
- end)
-
- if row then
- row._cachedMaxRowspan = maxRowspan
- end
-
- return maxRowspan
- end
-
- Array.forEach(children, function(child)
- if Class.instanceOf(child, Table2Row) then
- if groupRemaining == 0 then
- toggleStripe()
- end
-
- local maxRowspan = getRowMaxRowspan(child)
- groupRemaining = math.max(groupRemaining, maxRowspan)
-
- table.insert(stripedChildren, Table2Contexts.BodyStripe{
- value = stripe,
- children = {child},
- })
-
- groupRemaining = groupRemaining - 1
- else
- table.insert(stripedChildren, child)
- end
- end)
-
return Table2Contexts.Section{
value = 'body',
- children = stripedChildren,
+ children = self.props.children or {},
}
end
diff --git a/stylesheets/commons/Table2.scss b/stylesheets/commons/Table2.scss
index 993055369a6..b269db89133 100644
--- a/stylesheets/commons/Table2.scss
+++ b/stylesheets/commons/Table2.scss
@@ -167,6 +167,26 @@ $table2-cell-padding-x: 0.75rem;
background-color: var( --clr-semantic-gold-18 );
}
}
+
+ &.table2__row--group-hover {
+ .theme--light & {
+ background-color: var( --clr-on-surface-light-primary-8 );
+ }
+
+ .theme--dark & {
+ background-color: var( --clr-on-surface-dark-primary-8 );
+ }
+ }
+
+ &.table2__row--highlighted.table2__row--group-hover {
+ .theme--light & {
+ background-color: var( --clr-semantic-gold-85 );
+ }
+
+ .theme--dark & {
+ background-color: var( --clr-semantic-gold-18 );
+ }
+ }
}
&__title {