mobile table rendering

This commit is contained in:
2026-05-21 17:43:56 +02:00
parent 0c9b8f94a8
commit 9cd212876c
4 changed files with 457 additions and 39 deletions
+140
View File
@@ -14,7 +14,146 @@
});
*/
(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 toArray(list) {
return Array.prototype.slice.call(list || []);
}
function parsePixelLength(value) {
if (!value) {
return 0;
}
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;
}
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;
@@ -63,3 +202,4 @@ $(document).ready(function () {
};
});
});
}());
+69
View File
@@ -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 {
+153
View File
@@ -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');
+56
View File
@@ -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"
);