Filters (web/excel-filter.js), modeled on Excel's AutoFilter: - Every column header gets a dropdown: Sort A to Z / Z to A, Clear Filter From "<column>", Search, a checkbox list of the column's values with (Select All) and (Blanks), OK / Cancel. Values come from rows passing the other columns' filters; filters on several columns combine. Tags and software lists are split into individual items. - The button shows a funnel when filtered and an arrow when sorted, and a "Clear filters (n)" button appears beside the group's copy buttons - Replaces the old per-column filter row (which also removes the gap between header and filter rows); hooks the existing filter passes so global search, counts and worker re-renders keep working - Menus are fixed-position on <body>, so sticky headers and neighbouring tables can't cover them; on phones they open as a bottom sheet - Workbench stacks get the same menus (sorting done client-side) - Custom View's filter icon opens the same menu Layout: - Sticky headers use separated borders so no hairline gap opens - Phones/tablets: a sticky bar of column chips (with active counts and "Clear all") opens the filter menus - Custom View fills the page like the other views on desktop; it still scrolls sideways when wide, with its header pinned under the group header - Custom View on phones/tablets: a "Card fields" sheet to choose, order and remove the fields shown on each card Demo: first visit puts 6 PCs (one per migration stage) on the Workbench.
131 lines
5.9 KiB
JavaScript
131 lines
5.9 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);
|
|
|
|
}
|
|
|
|
// --- 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
|
|
const top = Math.max(0, navH + ghH - 1) + 'px';
|
|
setVar(table, '--sticky-top', top);
|
|
if (table.parentElement) setVar(table.parentElement, '--sticky-top', top); // for the filter chip bar
|
|
});
|
|
pinCustomHeader();
|
|
}
|
|
|
|
// Custom View can be wider than the page, so it scrolls sideways in its own box, and a
|
|
// sideways-scrolling box can't contain a position:sticky header. Instead its header is
|
|
// shifted down by exactly the amount needed to stay pinned under the group header.
|
|
function pinCustomHeader() {
|
|
const table = document.getElementById('table-custom');
|
|
if (!table) return;
|
|
if (cardsQuery.matches || !table.offsetParent || !table.tHead) { setVar(table, '--pin-y', '0px'); return; }
|
|
const top = parseFloat(table.style.getPropertyValue('--sticky-top')) || 0;
|
|
const r = table.getBoundingClientRect();
|
|
const headH = table.tHead.getBoundingClientRect().height;
|
|
const y = Math.max(0, Math.min(top - r.top, r.height - headH));
|
|
setVar(table, '--pin-y', y + '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 });
|
|
window.addEventListener('scroll', pinCustomHeader, { passive: true }); // same frame: no lag
|
|
// 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();
|
|
})();
|