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 {