From 9cd212876c798162cf76d05e3a1fdbd948e8d967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Piasecki?= Date: Thu, 21 May 2026 17:43:56 +0200 Subject: [PATCH] mobile table rendering --- public/client.js | 218 ++++++++++++++++++---- scss/westgate/_wiki-prose.scss | 69 +++++++ tests/wiki-mobile-table-client.test.js | 153 +++++++++++++++ tests/wiki-mobile-table-rendering.test.js | 56 ++++++ 4 files changed, 457 insertions(+), 39 deletions(-) create mode 100644 tests/wiki-mobile-table-client.test.js create mode 100644 tests/wiki-mobile-table-rendering.test.js diff --git a/public/client.js b/public/client.js index c9d391d..4634899 100644 --- a/public/client.js +++ b/public/client.js @@ -14,52 +14,192 @@ }); */ -$(document).ready(function () { - require(['api'], function (api) { - const originalGet = api.get.bind(api); - let categoryClassMapPromise; +(function () { + const WG_TABLE_SCROLL_CLASS = 'wg-mobile-table-scroll'; + const WIKI_TABLE_SELECTOR = '.westgate-wiki .wiki-page-content.wiki-article-prose > .card-body table'; + const WIKI_TABLE_MIN_COLUMN_WIDTH = 40; + const WIKI_TABLE_DESKTOP_MIN_WIDTH = 720; + const SKIP_TABLE_SELECTOR = [ + `.${WG_TABLE_SCROLL_CLASS}`, + '.tableWrapper', + '.table-responsive', + '.table-responsive-sm', + '.table-responsive-md', + 'figure.table', + '.wiki-infobox', + '.wiki-alignment-table', + ].join(', '); - function getCategoryClassMap() { - if (!categoryClassMapPromise) { - categoryClassMapPromise = new Promise((resolve) => { - originalGet('/categories', {}, function (err, data) { - if (err || !data || !Array.isArray(data.categories)) { - return resolve({}); - } + function toArray(list) { + return Array.prototype.slice.call(list || []); + } - const classMap = data.categories.reduce((memo, category) => { - if (category && category.cid !== undefined) { - memo[String(category.cid)] = category.class || ''; - } - return memo; - }, {}); - - resolve(classMap); - }); - }); - } - - return categoryClassMapPromise; + function parsePixelLength(value) { + if (!value) { + return 0; } - api.get = function (url, data, callback) { - if (url !== '/search/categories' || typeof callback !== 'function') { - return originalGet(url, data, callback); + const match = String(value).trim().match(/^([0-9]+(?:\.[0-9]+)?)px$/i); + return match ? parseFloat(match[1]) : 0; + } + + function getInlinePixelWidth(element) { + const styledWidth = element && element.style ? parsePixelLength(element.style.width) : 0; + if (styledWidth) { + return styledWidth; + } + + if (!element || typeof element.getAttribute !== 'function') { + return 0; + } + + const styleAttribute = element.getAttribute('style') || ''; + const widthMatch = styleAttribute.match(/(?:^|;)\s*width\s*:\s*([0-9]+(?:\.[0-9]+)?)px(?:\s*;|$)/i); + return widthMatch ? parseFloat(widthMatch[1]) : 0; + } + + function tableUsesFluidWidth(table) { + const styledWidth = table && table.style ? String(table.style.width || '') : ''; + if (/%$/.test(styledWidth.trim())) { + return true; + } + + if (!table || typeof table.getAttribute !== 'function') { + return false; + } + + const styleAttribute = table.getAttribute('style') || ''; + return /(?:^|;)\s*width\s*:\s*[0-9]+(?:\.[0-9]+)?%(?:\s*;|$)/i.test(styleAttribute); + } + + function getTableColumnCount(table) { + let columnCount = 0; + + if (table && typeof table.querySelectorAll === 'function') { + columnCount = Math.max(columnCount, table.querySelectorAll('colgroup col').length); + + toArray(table.querySelectorAll('tr')).forEach((row) => { + const rowColumnCount = toArray(row.children).reduce((count, cell) => { + const span = parseInt(cell.getAttribute && cell.getAttribute('colspan'), 10) || cell.colSpan || 1; + return count + Math.max(1, span); + }, 0); + columnCount = Math.max(columnCount, rowColumnCount); + }); + } + + return columnCount; + } + + function getTableColumnWidthFloor(table, columnCount) { + if (!table || typeof table.querySelectorAll !== 'function' || !columnCount) { + return 0; + } + + const explicitColumns = toArray(table.querySelectorAll('colgroup col')) + .map(getInlinePixelWidth) + .filter(width => width > 0); + const explicitWidth = explicitColumns.reduce((total, width) => total + width, 0); + const inferredColumns = Math.max(0, columnCount - explicitColumns.length); + + return explicitWidth + (inferredColumns * WIKI_TABLE_MIN_COLUMN_WIDTH); + } + + function getRenderedWidth(element) { + if (element && typeof element.getBoundingClientRect === 'function') { + return element.getBoundingClientRect().width || 0; + } + + return element && element.offsetWidth ? element.offsetWidth : 0; + } + + function setMobileTableMinWidth(table, wrapper) { + const columnCount = getTableColumnCount(table); + const measuredWidth = getRenderedWidth(table); + const columnWidthFloor = getTableColumnWidthFloor(table, columnCount); + const desktopWidthFloor = tableUsesFluidWidth(table) && columnCount >= 8 ? WIKI_TABLE_DESKTOP_MIN_WIDTH : 0; + const minWidth = Math.ceil(Math.max(measuredWidth, columnWidthFloor, desktopWidthFloor)); + + if (minWidth > 0 && wrapper && wrapper.style && typeof wrapper.style.setProperty === 'function') { + wrapper.style.setProperty('--wg-mobile-table-min-width', `${minWidth}px`); + } + } + + function wrapWestgateWikiTables(root) { + const scope = root && typeof root.querySelectorAll === 'function' ? root : document; + if (!scope || typeof scope.querySelectorAll !== 'function') { + return; + } + + toArray(scope.querySelectorAll(WIKI_TABLE_SELECTOR)).forEach((table) => { + if (!table || !table.parentNode || table.closest(SKIP_TABLE_SELECTOR)) { + return; } - return originalGet(url, data, function (err, payload) { - if (err || !payload || !Array.isArray(payload.categories)) { - return callback(err, payload); + const wrapper = document.createElement('div'); + wrapper.className = WG_TABLE_SCROLL_CLASS; + wrapper.setAttribute('tabindex', '0'); + wrapper.setAttribute('aria-label', 'Scrollable table'); + table.parentNode.insertBefore(wrapper, table); + wrapper.appendChild(table); + setMobileTableMinWidth(table, wrapper); + }); + } + + window.westgateTheme = window.westgateTheme || {}; + window.westgateTheme.wrapWikiTables = wrapWestgateWikiTables; + + $(document).ready(function () { + wrapWestgateWikiTables(document); + $(window).on('action:ajaxify.end', function () { + wrapWestgateWikiTables(document); + }); + + require(['api'], function (api) { + const originalGet = api.get.bind(api); + let categoryClassMapPromise; + + function getCategoryClassMap() { + if (!categoryClassMapPromise) { + categoryClassMapPromise = new Promise((resolve) => { + originalGet('/categories', {}, function (err, data) { + if (err || !data || !Array.isArray(data.categories)) { + return resolve({}); + } + + const classMap = data.categories.reduce((memo, category) => { + if (category && category.cid !== undefined) { + memo[String(category.cid)] = category.class || ''; + } + return memo; + }, {}); + + resolve(classMap); + }); + }); } - getCategoryClassMap().then((classMap) => { - payload.categories = payload.categories.map(category => ({ - ...category, - class: category.class || classMap[String(category.cid)] || '', - })); - callback(null, payload); + return categoryClassMapPromise; + } + + api.get = function (url, data, callback) { + if (url !== '/search/categories' || typeof callback !== 'function') { + return originalGet(url, data, callback); + } + + return originalGet(url, data, function (err, payload) { + if (err || !payload || !Array.isArray(payload.categories)) { + return callback(err, payload); + } + + getCategoryClassMap().then((classMap) => { + payload.categories = payload.categories.map(category => ({ + ...category, + class: category.class || classMap[String(category.cid)] || '', + })); + callback(null, payload); + }); }); - }); - }; + }; + }); }); -}); +}()); diff --git a/scss/westgate/_wiki-prose.scss b/scss/westgate/_wiki-prose.scss index ebce790..9466138 100644 --- a/scss/westgate/_wiki-prose.scss +++ b/scss/westgate/_wiki-prose.scss @@ -1167,6 +1167,75 @@ font-family: var(--wg-font-code) !important; } +.westgate-wiki .wiki-article-prose .wg-mobile-table-scroll { + display: contents; +} + +@media (max-width: 767.98px) { + .westgate-wiki .wiki-article-prose .wg-mobile-table-scroll, + .westgate-wiki .wiki-article-prose :where(.tableWrapper, .table-responsive, .table-responsive-sm, .table-responsive-md, figure.table), + .westgate-wiki-compose .wiki-article-prose .ck.ck-editor__editable_inline.ck-content :where(.tableWrapper, .table-responsive, .table-responsive-sm, .table-responsive-md, figure.table), + .westgate-wiki-compose .ck.ck-editor__editable_inline.wiki-article-prose.ck-content :where(.tableWrapper, .table-responsive, .table-responsive-sm, .table-responsive-md, figure.table) { + border: 1px solid rgba(194, 163, 90, 0.14); + border-radius: 7px; + box-shadow: + inset -1.25rem 0 1rem -1rem rgba(194, 163, 90, 0.16), + 0 0.6rem 1.4rem rgba(0, 0, 0, 0.16); + display: block; + inline-size: 100%; + max-inline-size: 100%; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-color: rgba(194, 163, 90, 0.5) rgba(8, 7, 10, 0.48); + scrollbar-width: thin; + } + + .westgate-wiki .wiki-article-prose .wg-mobile-table-scroll:focus { + outline: 2px solid rgba(194, 163, 90, 0.42); + outline-offset: 2px; + } + + .westgate-wiki .wiki-article-prose .wg-mobile-table-scroll > table, + .westgate-wiki .wiki-article-prose :where(.tableWrapper, .table-responsive, .table-responsive-sm, .table-responsive-md, figure.table) > table, + .westgate-wiki-compose .wiki-article-prose .ck.ck-editor__editable_inline.ck-content :where(.tableWrapper, .table-responsive, .table-responsive-sm, .table-responsive-md, figure.table) > table, + .westgate-wiki-compose .ck.ck-editor__editable_inline.wiki-article-prose.ck-content :where(.tableWrapper, .table-responsive, .table-responsive-sm, .table-responsive-md, figure.table) > table { + margin-block: 0; + } + + .westgate-wiki .wiki-article-prose .wg-mobile-table-scroll > table, + .westgate-wiki-compose .wiki-article-prose .ck.ck-editor__editable_inline.ck-content table, + .westgate-wiki-compose .ck.ck-editor__editable_inline.wiki-article-prose.ck-content table { + max-inline-size: none; + max-width: none; + } + + .westgate-wiki .wiki-article-prose .wg-mobile-table-scroll > table { + min-inline-size: var(--wg-mobile-table-min-width, max-content); + } + + .westgate-wiki .wiki-article-prose .wg-mobile-table-scroll > table.wiki-table-layout-fixed[style*="width:100%"], + .westgate-wiki .wiki-article-prose .wg-mobile-table-scroll > table.wiki-table-layout-fixed[style*="width: 100%"], + .westgate-wiki-compose .wiki-article-prose .ck.ck-editor__editable_inline.ck-content table.wiki-table-layout-fixed[style*="width:100%"], + .westgate-wiki-compose .wiki-article-prose .ck.ck-editor__editable_inline.ck-content table.wiki-table-layout-fixed[style*="width: 100%"], + .westgate-wiki-compose .ck.ck-editor__editable_inline.wiki-article-prose.ck-content table.wiki-table-layout-fixed[style*="width:100%"], + .westgate-wiki-compose .ck.ck-editor__editable_inline.wiki-article-prose.ck-content table.wiki-table-layout-fixed[style*="width: 100%"] { + min-width: var(--wg-mobile-table-min-width, max-content); + } + + .westgate-wiki .wiki-article-prose .wg-mobile-table-scroll > table:not([style*="width"]), + .westgate-wiki-compose .wiki-article-prose .ck.ck-editor__editable_inline.ck-content table:not([style*="width"]), + .westgate-wiki-compose .ck.ck-editor__editable_inline.wiki-article-prose.ck-content table:not([style*="width"]) { + min-inline-size: var(--wg-mobile-table-min-width, max-content); + } + + .westgate-wiki .wiki-article-prose :where(th, td), + .westgate-wiki-compose .wiki-article-prose .ck.ck-editor__editable_inline.ck-content :where(th, td), + .westgate-wiki-compose .ck.ck-editor__editable_inline.wiki-article-prose.ck-content :where(th, td) { + padding: 0.48rem 0.62rem; + } +} + .westgate-wiki-compose .wiki-article-prose .ck.ck-editor__editable_inline.ck-content pre[data-language]::after, .westgate-wiki-compose .ck.ck-editor__editable_inline.wiki-article-prose.ck-content pre[data-language]::after, .westgate-wiki-compose .ck-code-block-language-label { diff --git a/tests/wiki-mobile-table-client.test.js b/tests/wiki-mobile-table-client.test.js new file mode 100644 index 0000000..ec9f704 --- /dev/null +++ b/tests/wiki-mobile-table-client.test.js @@ -0,0 +1,153 @@ +'use strict'; + +const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); +const vm = require('vm'); + +const source = fs.readFileSync( + path.join(__dirname, '..', 'public', 'client.js'), + 'utf8' +); + +const readyCallbacks = []; +const eventHandlers = []; +const fakeApi = { + get: function () {}, +}; +const fakeDocument = { + querySelectorAll: function () { + return []; + }, + createElement: function () { + throw new Error('createElement should not be called during initialisation'); + }, +}; + +function fakeJquery() { + return { + ready: function (callback) { + readyCallbacks.push(callback); + }, + on: function (eventName, callback) { + eventHandlers.push({ eventName, callback }); + }, + }; +} + +const context = { + console, + document: fakeDocument, + require: function (deps, callback) { + assert.equal(deps.length, 1); + assert.equal(deps[0], 'api'); + callback(fakeApi); + }, + window: {}, + $: fakeJquery, +}; + +vm.runInNewContext(source, context, { filename: 'public/client.js' }); +readyCallbacks.forEach(callback => callback()); + +assert.equal( + typeof (context.window.westgateTheme && context.window.westgateTheme.wrapWikiTables), + 'function', + 'client should expose a testable wiki table wrapper helper' +); + +assert( + eventHandlers.some(handler => handler.eventName === 'action:ajaxify.end'), + 'client should rerun wiki table wrapping after ajaxify navigation' +); + +let createdWrapper; +let insertedNode; +const parent = { + insertBefore: function (node, before) { + insertedNode = { node, before }; + node.parentNode = this; + }, +}; +const table = { + style: {}, + parentNode: parent, + closest: function (selector) { + assert( + selector.includes('wg-mobile-table-scroll') || + selector.includes('tableWrapper') || + selector.includes('wiki-infobox'), + `unexpected closest selector: ${selector}` + ); + return null; + }, + getAttribute: function (name) { + return name === 'style' ? 'width:100%' : null; + }, + getBoundingClientRect: function () { + return { width: 320 }; + }, + querySelectorAll: function (selector) { + if (selector === 'colgroup col') { + return [ + { style: { width: '61px' }, getAttribute: function () { return 'width:61px'; } }, + { style: { width: '80px' }, getAttribute: function () { return 'width:80px'; } }, + ]; + } + + if (selector === 'tr') { + return [ + { + children: Array.from({ length: 10 }, () => ({ + getAttribute: function () { + return null; + }, + })), + }, + ]; + } + + return []; + }, +}; +const root = { + querySelectorAll: function (selector) { + assert.equal( + selector, + '.westgate-wiki .wiki-page-content.wiki-article-prose > .card-body table' + ); + return [table]; + }, +}; + +context.document.createElement = function (tagName) { + assert.equal(tagName, 'div'); + createdWrapper = { + className: '', + child: null, + attributes: {}, + style: { + values: {}, + setProperty: function (name, value) { + this.values[name] = value; + }, + }, + appendChild: function (child) { + this.child = child; + child.parentNode = this; + }, + setAttribute: function (name, value) { + this.attributes[name] = value; + }, + }; + return createdWrapper; +}; + +context.window.westgateTheme.wrapWikiTables(root); + +assert.equal(createdWrapper.className, 'wg-mobile-table-scroll'); +assert.equal(createdWrapper.attributes.tabindex, '0'); +assert.equal(insertedNode.node, createdWrapper); +assert.equal(insertedNode.before, table); +assert.equal(createdWrapper.child, table); +assert.equal(createdWrapper.style.values['--wg-mobile-table-min-width'], '720px'); diff --git a/tests/wiki-mobile-table-rendering.test.js b/tests/wiki-mobile-table-rendering.test.js new file mode 100644 index 0000000..bf45ba9 --- /dev/null +++ b/tests/wiki-mobile-table-rendering.test.js @@ -0,0 +1,56 @@ +const assert = require("assert"); +const fs = require("fs"); +const path = require("path"); + +const stylesheet = fs.readFileSync( + path.join(__dirname, "..", "scss", "westgate", "_wiki-prose.scss"), + "utf8" +); + +const mobileMediaMatch = stylesheet.match(/@media\s*\(max-width:\s*767\.98px\)\s*\{[\s\S]*\n\}/); +assert(mobileMediaMatch, "Expected a mobile-width table rendering media query"); + +const mobileStyles = mobileMediaMatch[0]; + +[ + ".westgate-wiki .wiki-article-prose .wg-mobile-table-scroll", + ".westgate-wiki .wiki-article-prose .wg-mobile-table-scroll > table", + ".westgate-wiki .wiki-article-prose .wg-mobile-table-scroll > table.wiki-table-layout-fixed[style*=\"width:100%\"]", +].forEach(selector => { + assert( + mobileStyles.includes(selector), + `${selector} should receive mobile table handling without changing article HTML` + ); +}); + +[ + "overflow-x: auto", + "-webkit-overflow-scrolling: touch", + "max-inline-size: 100%", + "max-inline-size: none", + "min-inline-size: var(--wg-mobile-table-min-width, max-content)", + "min-width: var(--wg-mobile-table-min-width, max-content)", +].forEach(declaration => { + assert( + mobileStyles.includes(declaration), + `Mobile wiki tables should include ${declaration}` + ); +}); + +[ + "attr(data-label)", + "wiki-mobile-table", + ".wiki-article-prose tr {\n\t\tdisplay: block", + ".wiki-article-prose td::before", + ".wiki-article-prose th::before", +].forEach(fragment => { + assert( + !mobileStyles.includes(fragment), + `Mobile table rendering must not use stacked/card conversion: ${fragment}` + ); +}); + +assert( + !mobileStyles.includes(".westgate-wiki .wiki-page-content.wiki-article-prose > .card-body {\n\t\tmax-inline-size: 100%;\n\t\toverflow-x: auto"), + "Mobile table scrolling should be scoped to table wrappers, not the whole article body" +);