Files
sow-nodebb-theme/public/client.js
T
archvillainetteandClaude Fable 5 7add4bf2e6 Apply remaining audit fixes; document radii and code palette in DESIGN.md
- Token sweep: exact-value gold literals now use --wg-border /
  --wg-border-soft / --wg-focus
- Topbar: drop invalid role="menuitem" (no parent role="menu")
- topics_list: topic-select icons get role="checkbox", tabindex,
  aria-label; client.js makes them keyboard-operable and syncs
  aria-checked
- category.tpl: copy-handle link gets an accessible name, "View
  Original" moved to i18n, external-link icon aria-hidden, stretched
  link inside aria-hidden alert removed from tab order
- Footer: all copy moved to [[westgate:footer.*]] keys; year injected
  via filter:middleware.renderFooter instead of hard-coded 2026
- client.js: batch table measurements before writes (no interleaved
  reflow), .catch on the category class map so search results never
  hang, category selector match uses fw-bold class instead of inline
  style
- Forced-colors focus fallback: transparent outline alongside the
  box-shadow focus ring
- DESIGN.md: real radius scale (3/4/6/8px), code-block palette,
  motion & modes section, placeholder alpha

Verified against the sow-nodebb dev container: themed code blocks
render in posts, footer i18n + year, checkbox roles in served HTML.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 15:40:52 +02:00

309 lines
9.2 KiB
JavaScript

'use strict';
/*
Hey there!
This is the client file for your theme. If you need to do any client-side work in javascript,
this is where it needs to go.
You can listen for page changes by writing something like this:
$(window).on('action:ajaxify.end', function(ev, data) {
var url = data.url;
console.log('I am now at: ' + url);
});
*/
(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',
'li[component="post"] .content table',
].join(', ');
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 measureMobileTableMinWidth(table) {
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;
return Math.ceil(Math.max(measuredWidth, columnWidthFloor, desktopWidthFloor));
}
function applyMobileTableMinWidth(wrapper, minWidth) {
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;
}
const wrapped = [];
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);
wrapped.push({ table, wrapper });
});
// batch: measure every table first, then write, so reads and writes
// don't interleave and force repeated reflows
wrapped
.map(({ table, wrapper }) => ({ wrapper, minWidth: measureMobileTableMinWidth(table) }))
.forEach(({ wrapper, minWidth }) => {
applyMobileTableMinWidth(wrapper, minWidth);
});
}
function getWestgateTopbar() {
if (!document || typeof document.querySelector !== 'function') {
return null;
}
return document.querySelector('[data-wg-topbar]');
}
function closeWestgateTopbarDrawer(topbar) {
if (!topbar) {
return;
}
topbar.classList.remove('is-drawer-open');
const burger = topbar.querySelector('[data-wg-burger]');
if (burger) {
burger.setAttribute('aria-expanded', 'false');
}
}
function initWestgateTopbar() {
const topbar = getWestgateTopbar();
if (!topbar) {
return;
}
if (!window.westgateTheme.topbarEventsBound) {
document.addEventListener('click', (event) => {
const currentTopbar = getWestgateTopbar();
if (!currentTopbar) {
return;
}
const burger = event.target.closest && event.target.closest('[data-wg-burger]');
if (burger && currentTopbar.contains(burger)) {
event.preventDefault();
event.stopPropagation();
const isOpen = !currentTopbar.classList.contains('is-drawer-open');
currentTopbar.classList.toggle('is-drawer-open', isOpen);
burger.setAttribute('aria-expanded', String(isOpen));
return;
}
if (!currentTopbar.contains(event.target)) {
closeWestgateTopbarDrawer(currentTopbar);
}
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') {
return;
}
const currentTopbar = getWestgateTopbar();
closeWestgateTopbarDrawer(currentTopbar);
});
window.westgateTheme.topbarEventsBound = true;
}
$(document)
.off('shown.bs.dropdown.westgateTopbar')
.on('shown.bs.dropdown.westgateTopbar', '.wg-topbar [component="sidebar/search"]', function () {
$(this).find('[component="search/fields"] input[name="query"]').trigger('focus');
});
}
window.westgateTheme = window.westgateTheme || {};
window.westgateTheme.wrapWikiTables = wrapWestgateWikiTables;
window.westgateTheme.initTopbar = initWestgateTopbar;
$(document).ready(function () {
wrapWestgateWikiTables(document);
initWestgateTopbar();
// topic-select checkboxes are <i> elements; make them keyboard-operable
$(document).on('keydown', 'i[component="topic/select"]', function (ev) {
if (ev.key === 'Enter' || ev.key === ' ') {
ev.preventDefault();
$(this).trigger('click');
}
});
$(document).on('click', 'i[component="topic/select"]', function () {
// core toggles the icon class in its own click handler; sync after it runs
setTimeout(() => {
this.setAttribute('aria-checked', this.classList.contains('fa-check-square') ? 'true' : 'false');
}, 0);
});
$(window).on('action:ajaxify.end', function () {
wrapWestgateWikiTables(document);
initWestgateTopbar();
closeWestgateTopbarDrawer(getWestgateTopbar());
});
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);
});
});
}
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);
}).catch(() => {
// class map is cosmetic; never block search results on it
callback(null, payload);
});
});
};
});
});
}());