Files
usmt-dash/web/layout.js
T
Johan Sandoval d24be4a2d6 Polish sticky headers, Custom View, docs page and dialogs
- Status tables: the whole header (column names + filters) sticks as one
  block, tucked 1px under the group header, so no gap can open between
  the rows; offsets use fractional heights
- Phones/tablets: the filter panel sticks under the group header too, and
  is opaque and compact (three filters per row on phones)
- Custom View: fix the group header being pushed over the column header
  (it lives inside the view's own scroll box); the column header now pins
  to the top of that box on desktop, and the header strip sticks on phones
- All Devices: group title is "All Devices (N)" instead of "Missing
  Software", matching the page title count
- Documentation fits the window on desktop: only the article list and the
  article scroll; placeholder no longer says "from the left"
- Dialogs sit above the terminal dock so it can't cover their last rows;
  page bottom padding follows the dock's measured height
2026-10-06 16:58:05 +00:00

140 lines
6.3 KiB
JavaScript

/*
* Layout helpers (see layout.css).
*
* Sticky headers: measures the top nav and each table's group header so the column
* header rows can stick directly beneath them (--nav-h, --sticky-top, --thead-r1).
*
* Card view: on phones and tablets each table row is shown as a card, with every cell
* rendered as "Column name: value". CSS can't look up a cell's column header, so this
* copies each header's text onto its cells as data-label.
*
* The dashboard re-renders its tables constantly, so both are reapplied (once per frame
* at most) whenever the DOM changes.
*/
(function () {
'use strict';
const cardsQuery = window.matchMedia('(max-width: 1100px)');
const clean = text => text.replace(/[\u2195\u25B2\u25BC\u00D7\u2715]/g, '').replace(/\s+/g, ' ').trim();
function labelTable(table) {
const thead = table.tHead;
if (!thead) return;
// The header row is the one with the most text (Custom View has a drag-handle row above it)
let headerRow = null, best = -1;
for (const row of thead.rows) {
if (row.classList.contains('filter-row')) continue;
const named = Array.from(row.cells).filter(c => clean(c.textContent)).length;
if (named > best) { best = named; headerRow = row; }
}
if (!headerRow) return;
for (const row of thead.rows) {
if (row === headerRow) row.classList.add('card-label-row');
else if (!row.classList.contains('filter-row')) row.classList.add('card-aux-row');
}
const labels = Array.from(headerRow.cells).map(c => clean(c.textContent));
const apply = row => {
Array.from(row.cells).forEach((cell, i) => {
const label = cell.colSpan > 1 ? '' : (labels[i] || '');
if (cell.dataset.label !== label) cell.dataset.label = label;
});
};
thead.querySelectorAll('tr.filter-row').forEach(apply);
for (const body of table.tBodies) for (const row of body.rows) apply(row);
addFilterToggle(table, thead.querySelector('tr.filter-row'));
}
// Column filters take a lot of room above every card group, so they fold behind a
// button that also shows how many filters are active.
function addFilterToggle(table, filterRow) {
if (!filterRow) return;
let btn = table.previousElementSibling;
if (!btn || !btn.classList.contains('card-filter-toggle')) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'card-filter-toggle';
btn.addEventListener('click', () => { table.classList.toggle('show-filters'); updateToggle(table, btn); });
table.parentNode.insertBefore(btn, table);
filterRow.addEventListener('input', () => updateToggle(table, btn));
filterRow.addEventListener('change', () => updateToggle(table, btn));
}
updateToggle(table, btn);
}
function updateToggle(table, btn) {
const row = table.tHead && table.tHead.querySelector('tr.filter-row');
if (!row) return;
const active = Array.from(row.querySelectorAll('input[type="text"]')).filter(i => i.value.trim()).length
+ row.querySelectorAll('input[type="checkbox"]:checked').length;
const open = table.classList.contains('show-filters');
const html = `${open ? 'Hide' : 'Show'} filters${active ? ` <span class="count">${active}</span>` : ''}`;
if (btn.innerHTML !== html) btn.innerHTML = html;
}
// --- Sticky offsets ---------------------------------------------------------------
function setVar(el, name, value) {
if (el.style.getPropertyValue(name) !== value) el.style.setProperty(name, value);
}
function groupHeaderFor(table) {
// The group header is a sibling of the table or of its wrapper
for (let el = table.parentElement, depth = 0; el && depth < 3; el = el.parentElement, depth++) {
const gh = el.querySelector(':scope > .group-header');
if (gh) return gh;
}
return null;
}
function updateSticky() {
const root = document.documentElement;
const nav = document.querySelector('.top-nav');
const navH = nav ? nav.getBoundingClientRect().height : 60;
setVar(root, '--nav-h', navH + 'px');
const dock = document.getElementById('usmt-term');
setVar(root, '--dock-h', (dock ? Math.ceil(dock.getBoundingClientRect().height) : 0) + 'px');
document.querySelectorAll('table.master-table').forEach(table => {
if (!table.offsetParent) return; // hidden page/group: keep old values
const gh = groupHeaderFor(table);
const ghH = gh && getComputedStyle(gh).position === 'sticky' ? gh.getBoundingClientRect().height : 0;
// 1px overlap tucks the table header under the group header: no hairline gap
setVar(table, '--sticky-top', Math.max(0, navH + ghH - 1) + 'px');
});
}
let queued = false;
function run() {
queued = false;
updateSticky();
if (!cardsQuery.matches) return;
document.querySelectorAll('table.master-table').forEach(labelTable);
}
function schedule() {
if (queued) return;
queued = true;
requestAnimationFrame(run);
}
function start() {
new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
if (cardsQuery.addEventListener) cardsQuery.addEventListener('change', schedule);
else cardsQuery.addListener(schedule);
window.addEventListener('resize', schedule);
// Groups and pages are shown by toggling display (not a DOM change), so also
// re-measure as the user scrolls; writes only happen when a value changes.
window.addEventListener('scroll', schedule, { passive: true });
// The terminal dock opens, closes and resizes without a page resize
if (window.ResizeObserver) {
const watchDock = () => {
const dock = document.getElementById('usmt-term');
if (dock) new ResizeObserver(schedule).observe(dock); else setTimeout(watchDock, 500);
};
watchDock();
}
schedule();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
else start();
})();