Excel-style column filters, full-page Custom View, card field editor

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.
This commit is contained in:
Johan Sandoval committed 2026-10-06 17:24:55 +00:00
1 parent d24be4a2d6
commit b216ce43f2
5 files changed
+788 -99

No files matched your search

+29
View File
@@ -937,6 +937,35 @@
let seen = false; let seen = false;
try { seen = localStorage.getItem('usmt_demo_seen') === '1'; } catch (e) {} try { seen = localStorage.getItem('usmt_demo_seen') === '1'; } catch (e) {}
if (!seen) this.about(true); if (!seen) this.about(true);
this.seedWorkbench();
},
// First visit: put 6 PCs on the Workbench (one per migration stage) so it isn't empty.
// Only when the visitor has no saved stacks; "Reset my changes" clears them again.
async seedWorkbench() {
try { if (localStorage.getItem('usmt_workbench_stacks')) return; } catch (e) { return; }
for (let i = 0; i < 60; i++) {
if (typeof apiData !== 'undefined' && apiData.filter(r => r["Backup Status"] && r["Backup Status"] !== "No Data").length > 20) break;
await sleep(500);
}
if (typeof apiData === 'undefined' || typeof myStacks === 'undefined') return;
try { if (localStorage.getItem('usmt_workbench_stacks')) return; } catch (e) { return; } // created meanwhile
const wanted = ["Backup In Progress", "Restore In Progress", "Backup Completed", "Completed", "Not Started", "Not Associated"];
const picks = [];
for (const status of wanted) {
const row = apiData.find(r => r["Backup Status"] === status && !picks.includes(r["Source Device"])
&& (status === "Completed" || r["Source Online"] === "Online"));
if (row) picks.push(row["Source Device"]);
}
for (const r of apiData) { // top up to 6 if a stage is empty
if (picks.length >= 6) break;
if (!["Skipped", "No Data"].includes(r["Backup Status"]) && !picks.includes(r["Source Device"])) picks.push(r["Source Device"]);
}
if (!picks.length) return;
myStacks = [{ id: 1, name: "Stack 1", list: picks.slice(0, 6).join('\n') }];
saveAndRender();
myStacks.forEach(st => refreshStackTable(st.id));
}, },
about(show) { about(show) {
this.modal.classList.toggle('show', show); this.modal.classList.toggle('show', show);
+587
View File
@@ -0,0 +1,587 @@
/*
* Excel-style column filters for every dashboard table.
*
* Modeled on Excel's AutoFilter: each column header gets a dropdown with
* Sort A to Z / Sort Z to A, Clear Filter From "<column>", a Search box, and a checkbox
* list of every value in the column with (Select All) and (Blanks), then OK / Cancel.
* The value list only shows values from rows that pass the OTHER columns' filters, filters
* on several columns combine (AND), and a filtered column's button shows a funnel.
* List-type columns (Tags, Missing/Specialty Software) are split so each tag or app is
* its own checkbox; a row matches if it has any of the checked items.
*
* It plugs into the dashboard's existing filter passes (applyFilters, filterSoftwareTab,
* filterCustomTab, refreshStackTable): those run first (global search etc.), then rows
* failing a column filter are hidden and the counts are updated. Menus are appended to
* <body> with a fixed position, so sticky headers and neighbouring tables can't cover them.
* On phones and tablets (card view) a sticky bar of column chips opens the same menus.
*/
(function () {
'use strict';
const cardsQuery = window.matchMedia('(max-width: 1100px)');
const sheetQuery = window.matchMedia('(max-width: 600px)');
const LIST_COLUMNS = /^(tags|missing software|specialty software)$/i;
const SKIP_COLUMNS = /(^|\s)apps$/i; // "Apps" button columns have nothing to filter
const BLANK = '(Blanks)';
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' });
const filters = {}; // table key -> { column key -> Set of allowed values }
const nativeSort = {}; // table key -> { col, dir } for tables without their own sort (Workbench)
const norm = s => String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
const cleanLabel = t => norm(String(t).replace(/[↕▲▼▾×✕]/g, ''));
const FUNNEL_SVG = '<svg viewBox="0 0 512 512" aria-hidden="true"><path fill="currentColor" d="M3.9 54.9C10.5 40.9 24.5 32 40 32H472c15.5 0 29.5 8.9 36.1 22.9s4.6 30.5-5.2 42.5L320 320.9V448c0 12.1-6.8 23.2-17.7 28.6s-23.8 4.3-33.5-3l-64-48c-8.1-6-12.8-15.5-12.8-25.6V320.9L9 97.3C-.7 85.4-2.8 68.8 3.9 54.9z"/></svg>';
const CARET_SVG = '<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M2 3.5 5 6.5 8 3.5" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const ARROW_UP_SVG = '<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M5 8.5V1.8M2.2 4.5 5 1.5l2.8 3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const ARROW_DOWN_SVG = '<svg viewBox="0 0 10 10" aria-hidden="true"><path d="M5 1.5v6.7M2.2 5.5 5 8.5l2.8-3" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>';
const SORT_ASC_SVG = '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M4 2 1 6h2v8h2V6h2L4 2zm5 1h6v2H9V3zm0 4h5v2H9V7zm0 4h4v2H9v-2z"/></svg>';
const SORT_DESC_SVG = '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M4 14 1 10h2V2h2v8h2l-3 4zM9 3h4v2H9V3zm0 4h5v2H9V7zm0 4h6v2H9v-2z"/></svg>';
const CLEAR_SVG = '<svg viewBox="0 0 16 16" aria-hidden="true"><path fill="currentColor" d="M1 2h13l-5 6v5l-3 1V8L1 2zm11.3 7.3 1.4 1.4L12.4 12l1.3 1.3-1.4 1.4L11 13.4l-1.3 1.3-1.4-1.4 1.3-1.3-1.3-1.3 1.4-1.4L11 10.6l1.3-1.3z"/></svg>';
// ------------------------------------------------------------------------------------
// Which table is this, and how does the dashboard filter / sort / refresh it?
// ------------------------------------------------------------------------------------
function adapter(table) {
if (!table || !table.id) return null;
const id = table.id;
if (id === 'table-custom') {
return {
key: 'custom', custom: true,
colKey: i => (typeof customColumns !== 'undefined' ? customColumns[i] : i),
rerun: () => filterCustomTab(),
sortable: () => true,
sort: (i, dir) => { sortStates['custom'] = { col: i, dir }; renderCustomTable(); },
sortDir: i => { const s = sortStates['custom']; return s && s.col === i ? s.dir : 'none'; }
};
}
if (id === 'table-software') {
return {
key: 'software',
colKey: i => i,
rerun: () => filterSoftwareTab(),
sortable: th => th.classList.contains('sortable-th') && th.hasAttribute('onclick'),
sort: (i, dir) => { sortStates['software'] = { col: i, dir }; renderSoftwareTable(); },
sortDir: i => { const s = sortStates['software']; return s && s.col === i ? s.dir : 'none'; }
};
}
if (table.closest('#stackGrid')) {
const sid = id.replace(/^table-/, '');
const key = 'stack-' + sid;
const stackId = () => { const s = (typeof myStacks !== 'undefined' ? myStacks : []).find(x => String(x.id) === sid); return s ? s.id : sid; };
return {
key, native: true,
colKey: i => i,
rerun: () => refreshStackTable(stackId()),
sortable: () => true,
sort: (i, dir) => { nativeSort[key] = { col: i, dir }; refreshStackTable(stackId()); },
sortDir: i => { const s = nativeSort[key]; return s && s.col === i ? s.dir : 'none'; }
};
}
if (table.closest('#masterList')) {
const g = id.replace(/^table-/, '');
return {
key: 'group-' + g, group: g,
colKey: i => i,
rerun: () => applyFilters(g),
sortable: th => th.classList.contains('sortable-th'),
sort: (i, dir) => { sortStates[g] = { col: i, dir }; renderMasterList(); },
sortDir: i => { const s = sortStates[g]; return s && s.col === i ? s.dir : 'none'; }
};
}
return null;
}
function headerRow(table) {
if (!table.tHead) return null;
let best = null, score = -1;
for (const row of table.tHead.rows) {
if (row.classList.contains('filter-row')) continue;
const n = Array.from(row.cells).filter(c => cleanLabel(c.textContent)).length;
if (n > score) { score = n; best = row; }
}
return best;
}
function columnsOf(table, ad) {
const row = headerRow(table);
if (!row) return [];
return Array.from(row.cells).map((th, idx) => {
const label = cleanLabel(th.textContent);
return {
idx, th, label,
list: LIST_COLUMNS.test(label),
filterable: !!label && !SKIP_COLUMNS.test(label),
sortable: !!label && !SKIP_COLUMNS.test(label) && !!ad.sortable(th)
};
});
}
const dataRows = table => Array.from(table.tBodies[0] ? table.tBodies[0].rows : [])
.filter(r => !(r.cells.length === 1 && r.cells[0].colSpan > 1));
function cellValues(td, col) {
let raw = td ? (td.getAttribute('data-val') != null ? td.getAttribute('data-val') : td.textContent) : '';
raw = norm(raw);
if (!raw) return [BLANK];
if (col.list) {
const parts = raw.split(',').map(norm).filter(Boolean);
return parts.length ? parts : [BLANK];
}
return [raw];
}
function activeFilters(ad, cols) {
const set = filters[ad.key];
if (!set) return [];
return cols.filter(c => c.filterable && set[ad.colKey(c.idx)]);
}
function passes(ad, cols, tr, exceptKey) {
const set = filters[ad.key];
if (!set) return true;
for (const col of cols) {
if (!col.filterable) continue;
const k = ad.colKey(col.idx);
if (k === exceptKey) continue;
const allowed = set[k];
if (!allowed) continue;
if (!cellValues(tr.cells[col.idx], col).some(v => allowed.has(v))) return false;
}
return true;
}
// ------------------------------------------------------------------------------------
// After the dashboard's own filter pass: hide rows failing a column filter, fix counts
// ------------------------------------------------------------------------------------
function afterFilterPass(table) {
const ad = adapter(table);
if (!ad) return;
table.classList.add('xf-table');
const cols = columnsOf(table, ad);
if (ad.native && nativeSort[ad.key] && nativeSort[ad.key].dir !== 'none') nativeSortRows(table, cols, nativeSort[ad.key]);
let visible = 0;
for (const tr of dataRows(table)) {
if (tr.style.display === 'none') continue;
if (!passes(ad, cols, tr, undefined)) { tr.style.display = 'none'; continue; }
visible++;
}
if (ad.group) {
const header = document.getElementById(`group-header-${ad.group}`);
if (header) header.innerText = `${ad.group.replace(/-/g, ' ')} (${visible})`;
}
if (ad.custom) {
const cnt = document.getElementById('customCount');
if (cnt) cnt.innerText = `(${visible})`;
}
if (typeof updateTotalCounts === 'function') updateTotalCounts();
decorate(table, ad, cols);
}
function nativeSortRows(table, cols, s) {
const col = cols[s.col];
if (!col) return;
const body = table.tBodies[0];
const rows = dataRows(table);
rows.sort((a, b) => {
const r = collator.compare(norm(a.cells[s.col] ? a.cells[s.col].textContent : ''), norm(b.cells[s.col] ? b.cells[s.col].textContent : ''));
return s.dir === 'desc' ? -r : r;
});
rows.forEach(r => body.appendChild(r));
}
// Header dropdown buttons, the "Clear filters" button, and the phone/tablet chip bar
function decorate(table, ad, cols) {
cols = cols || columnsOf(table, ad);
const active = activeFilters(ad, cols);
const activeKeys = new Set(active.map(c => ad.colKey(c.idx)));
// Header buttons (Custom View has its own filter icon, rerouted to our menu)
if (ad.custom) {
cols.forEach(c => {
const icon = c.th.querySelector('.col-filter-btn');
if (icon) icon.classList.toggle('xf-on', activeKeys.has(ad.colKey(c.idx)));
});
} else {
cols.forEach(c => {
if (!c.filterable && !c.sortable) return;
let btn = c.th.querySelector(':scope > .xf-btn');
if (!btn) {
btn = document.createElement('button');
btn.type = 'button';
btn.className = 'xf-btn no-copy';
btn.title = 'Sort and filter';
btn.addEventListener('click', e => { e.stopPropagation(); openMenu(table, c.idx, btn); });
c.th.appendChild(btn);
}
// Like Excel's button: funnel when filtered, an arrow when sorted, otherwise a caret
const on = activeKeys.has(ad.colKey(c.idx));
const dir = ad.sortDir(c.idx);
const state = on ? 'filtered' : (dir === 'asc' || dir === 'desc') ? dir : 'plain';
btn.classList.toggle('xf-on', on);
btn.classList.toggle('xf-sorted', state === 'asc' || state === 'desc');
if (btn.dataset.state !== state) {
btn.innerHTML = state === 'filtered' ? FUNNEL_SVG : state === 'asc' ? ARROW_UP_SVG : state === 'desc' ? ARROW_DOWN_SVG : CARET_SVG;
btn.dataset.state = state;
}
});
}
// "Clear filters" next to the group's copy buttons
const gh = groupHeaderFor(table);
const btnBox = gh && Array.from(gh.children).find(el => el.tagName === 'DIV');
if (btnBox) {
let clr = btnBox.querySelector('.xf-clear-all');
if (active.length && !clr) {
clr = document.createElement('button');
clr.type = 'button';
clr.className = 'copy-table-btn no-copy xf-clear-all';
clr.addEventListener('click', () => { delete filters[ad.key]; ad.rerun(); });
btnBox.insertBefore(clr, btnBox.firstChild);
}
if (clr) {
if (!active.length) clr.remove();
else if (clr.dataset.n !== String(active.length)) { // only touch the DOM on change
clr.innerHTML = `${CLEAR_SVG} Clear filters (${active.length})`;
clr.dataset.n = String(active.length);
}
}
}
// Card view: sticky bar of column chips
let bar = table.previousElementSibling;
if (!bar || !bar.classList.contains('xf-bar')) {
if (!cardsQuery.matches) return;
bar = document.createElement('div');
bar.className = 'xf-bar';
table.parentNode.insertBefore(bar, table);
}
const parts = [];
if (ad.custom) parts.push('<button type="button" class="xf-chip xf-fields" data-act="fields">&#9776; Card fields</button>');
cols.forEach(c => {
if (!c.filterable && !c.sortable) return;
const on = activeKeys.has(ad.colKey(c.idx));
const n = on ? filters[ad.key][ad.colKey(c.idx)].size : 0;
const dir = ad.sortDir(c.idx);
const arrow = dir === 'asc' ? ' &uarr;' : dir === 'desc' ? ' &darr;' : '';
parts.push(`<button type="button" class="xf-chip${on ? ' xf-on' : ''}" data-idx="${c.idx}">${on ? FUNNEL_SVG : ''}<span></span>${on ? ` <b>${n}</b>` : ''}${arrow}${CARET_SVG}</button>`);
});
if (active.length) parts.push('<button type="button" class="xf-chip xf-clear" data-act="clear">Clear all</button>');
const sig = JSON.stringify([parts, cols.map(c => c.label)]);
if (bar.dataset.sig !== sig) {
bar.innerHTML = parts.join('');
// labels via textContent (column names can come from user-chosen fields)
bar.querySelectorAll('.xf-chip[data-idx]').forEach(chip => {
chip.querySelector('span').textContent = cols[+chip.dataset.idx].label;
});
bar.dataset.sig = sig;
bar.onclick = e => {
const chip = e.target.closest('.xf-chip');
if (!chip) return;
if (chip.dataset.act === 'clear') { delete filters[ad.key]; ad.rerun(); return; }
if (chip.dataset.act === 'fields') { openFieldEditor(); return; }
openMenu(table, +chip.dataset.idx, chip);
};
}
}
function groupHeaderFor(table) {
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;
}
// ------------------------------------------------------------------------------------
// The dropdown menu
// ------------------------------------------------------------------------------------
let menu = null, backdrop = null, menuCtx = null;
function closeMenu() {
if (menu) menu.remove();
if (backdrop) backdrop.remove();
menu = backdrop = menuCtx = null;
document.removeEventListener('keydown', onMenuKey, true);
}
function onMenuKey(e) {
if (!menu) return;
if (e.key === 'Escape') { e.preventDefault(); closeMenu(); }
if (e.key === 'Enter' && e.target.closest && e.target.closest('.xf-menu')) { e.preventDefault(); const ok = menu.querySelector('.xf-ok'); if (ok && !ok.disabled) ok.click(); }
}
function el(tag, cls, text) {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
}
function openMenu(table, idx, anchor) {
if (menuCtx && menuCtx.table === table && menuCtx.idx === idx) { closeMenu(); return; }
closeMenu();
const ad = adapter(table);
if (!ad) return;
const cols = columnsOf(table, ad);
const col = cols[idx];
if (!col) return;
const key = ad.colKey(idx);
const current = filters[ad.key] && filters[ad.key][key];
// Values present in rows that pass the other columns' filters (like Excel)
const seen = new Set();
let hasBlank = false;
for (const tr of dataRows(table)) {
if (!passes(ad, cols, tr, key)) continue;
for (const v of cellValues(tr.cells[idx], col)) { if (v === BLANK) hasBlank = true; else seen.add(v); }
}
const values = Array.from(seen).sort(collator.compare);
if (hasBlank) values.push(BLANK);
menuCtx = { table, idx };
const sheet = sheetQuery.matches;
menu = el('div', 'xf-menu' + (sheet ? ' xf-sheet' : ''));
menu.setAttribute('role', 'dialog');
menu.setAttribute('aria-label', `Sort and filter ${col.label}`);
const head = el('div', 'xf-head');
head.appendChild(el('span', 'xf-title', col.label));
const x = el('button', 'xf-x', '×'); x.type = 'button'; x.title = 'Close'; x.onclick = closeMenu;
head.appendChild(x);
menu.appendChild(head);
const item = (svg, text, onClick, disabled) => {
const b = el('button', 'xf-item'); b.type = 'button';
b.innerHTML = svg; b.appendChild(el('span', null, text));
b.disabled = !!disabled; b.onclick = onClick;
menu.appendChild(b);
};
if (col.sortable) {
const dir = ad.sortDir(idx);
item(SORT_ASC_SVG, 'Sort A to Z', () => { closeMenu(); ad.sort(idx, dir === 'asc' ? 'none' : 'asc'); });
item(SORT_DESC_SVG, 'Sort Z to A', () => { closeMenu(); ad.sort(idx, dir === 'desc' ? 'none' : 'desc'); });
const marks = menu.querySelectorAll('.xf-item');
if (dir === 'asc') marks[0].classList.add('xf-checked');
if (dir === 'desc') marks[1].classList.add('xf-checked');
menu.appendChild(el('div', 'xf-sep'));
}
if (col.filterable) {
item(CLEAR_SVG, `Clear Filter From "${col.label}"`, () => {
if (filters[ad.key]) delete filters[ad.key][key];
closeMenu(); ad.rerun();
}, !current);
const search = el('input', 'xf-search'); search.type = 'search'; search.placeholder = 'Search';
menu.appendChild(search);
const list = el('div', 'xf-list');
const mkRow = (label, checked, cls) => {
const row = el('label', 'xf-opt' + (cls ? ' ' + cls : ''));
const cb = el('input'); cb.type = 'checkbox'; cb.checked = checked;
row.appendChild(cb); row.appendChild(el('span', null, label));
list.appendChild(row);
return { row, cb };
};
const all = mkRow('(Select All)', true, 'xf-all');
const opts = values.map(v => ({ v, ...mkRow(v, !current || current.has(v)) }));
if (!values.length) list.appendChild(el('div', 'xf-empty', 'No values'));
menu.appendChild(list);
const actions = el('div', 'xf-actions');
const ok = el('button', 'xf-ok', 'OK'); ok.type = 'button';
const cancel = el('button', 'xf-cancel', 'Cancel'); cancel.type = 'button';
actions.appendChild(ok); actions.appendChild(cancel);
menu.appendChild(actions);
const visibleOpts = () => opts.filter(o => !o.row.hidden);
const sync = () => {
const vis = visibleOpts();
const n = vis.filter(o => o.cb.checked).length;
all.cb.checked = vis.length > 0 && n === vis.length;
all.cb.indeterminate = n > 0 && n < vis.length;
ok.disabled = n === 0;
};
all.cb.onchange = () => { visibleOpts().forEach(o => { o.cb.checked = all.cb.checked; }); sync(); };
opts.forEach(o => { o.cb.onchange = sync; });
search.oninput = () => {
const q = search.value.trim().toLowerCase();
// Excel: searching shows the matching items, all ticked
opts.forEach(o => {
o.row.hidden = !!q && !o.v.toLowerCase().includes(q);
if (q) o.cb.checked = !o.row.hidden;
});
if (!q) opts.forEach(o => { o.cb.checked = !current || current.has(o.v); });
all.row.querySelector('span').textContent = q ? '(Select All Search Results)' : '(Select All)';
sync();
};
ok.onclick = () => {
const q = search.value.trim();
const chosen = new Set(visibleOpts().filter(o => o.cb.checked).map(o => o.v));
filters[ad.key] = filters[ad.key] || {};
if (!q && chosen.size === values.length) delete filters[ad.key][key]; // everything ticked = no filter
else filters[ad.key][key] = chosen;
closeMenu(); ad.rerun();
};
cancel.onclick = closeMenu;
sync();
setTimeout(() => { if (!sheet) search.focus(); }, 0);
}
if (sheet) {
backdrop = el('div', 'xf-backdrop');
backdrop.onclick = closeMenu;
document.body.appendChild(backdrop);
}
document.body.appendChild(menu);
if (!sheet) positionMenu(anchor);
document.addEventListener('keydown', onMenuKey, true);
}
function positionMenu(anchor) {
const r = anchor.getBoundingClientRect();
const w = menu.offsetWidth;
const dock = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--dock-h')) || 0;
let left = Math.min(r.left, window.innerWidth - w - 8);
left = Math.max(8, left);
let top = r.bottom + 4;
const room = window.innerHeight - dock - top - 8;
if (room < 260 && r.top > room) { // not enough room below: open upward
menu.style.maxHeight = Math.max(200, r.top - 8 - 60) + 'px';
top = Math.max(60, r.top - 4 - menu.offsetHeight);
} else {
menu.style.maxHeight = Math.max(200, room) + 'px';
}
menu.style.left = left + 'px';
menu.style.top = top + 'px';
}
// Close on outside clicks and when the page scrolls under a floating menu
document.addEventListener('mousedown', e => {
if (menu && !menu.contains(e.target) && !(e.target.closest && e.target.closest('.xf-btn, .xf-chip, .col-filter-btn'))) closeMenu();
}, true);
window.addEventListener('scroll', () => { if (menu && !menu.classList.contains('xf-sheet')) closeMenu(); }, { passive: true });
window.addEventListener('resize', closeMenu);
// ------------------------------------------------------------------------------------
// Custom View card fields (phones/tablets): choose and order what each card shows
// ------------------------------------------------------------------------------------
function openFieldEditor() {
closeMenu();
if (typeof customColumns === 'undefined') return;
backdrop = el('div', 'xf-backdrop'); backdrop.onclick = closeMenu;
menu = el('div', 'xf-menu xf-sheet xf-fields-sheet');
menuCtx = { fields: true };
document.body.appendChild(backdrop);
document.body.appendChild(menu);
document.addEventListener('keydown', onMenuKey, true);
renderFieldEditor();
}
function renderFieldEditor() {
if (!menu) return;
const label = id => { try { return getCustomColDef(id).label; } catch (e) { return id; } };
menu.innerHTML = '';
const head = el('div', 'xf-head');
head.appendChild(el('span', 'xf-title', 'Card fields'));
const x = el('button', 'xf-x', '×'); x.type = 'button'; x.onclick = closeMenu;
head.appendChild(x);
menu.appendChild(head);
menu.appendChild(el('div', 'xf-hint', 'Choose what each card shows and in what order. The first field is the card title.'));
const shown = el('div', 'xf-fields');
customColumns.forEach((id, i) => {
const row = el('div', 'xf-field');
row.appendChild(el('span', 'xf-field-name', label(id)));
if (i === 0) row.appendChild(el('span', 'xf-badge', 'Title'));
const btn = (txt, title, fn, disabled) => {
const b = el('button', 'xf-mini', txt); b.type = 'button'; b.title = title; b.disabled = !!disabled;
b.onclick = () => { fn(); renderFieldEditor(); };
row.appendChild(b);
};
btn('↑', 'Move up', () => moveCustomColumn(i, i - 1), i === 0);
btn('↓', 'Move down', () => moveCustomColumn(i, i + 1), i === customColumns.length - 1);
btn('−', 'Remove from cards', () => toggleCustomColumn(id), customColumns.length <= 1);
shown.appendChild(row);
});
menu.appendChild(shown);
const addWrap = el('div', 'xf-add');
const search = el('input', 'xf-search'); search.type = 'search'; search.placeholder = 'Add a field...';
addWrap.appendChild(search);
const list = el('div', 'xf-list');
getCustomCatalog().forEach(grp => {
const avail = grp.ids.filter(id => !customColumns.includes(id));
if (!avail.length) return;
const g = el('div', 'xf-group', grp.group);
list.appendChild(g);
avail.forEach(id => {
const b = el('button', 'xf-addfield'); b.type = 'button';
b.appendChild(el('span', null, '+ ' + label(id)));
b.onclick = () => { toggleCustomColumn(id); renderFieldEditor(); };
b.dataset.name = label(id).toLowerCase();
list.appendChild(b);
});
});
search.oninput = () => {
const q = search.value.trim().toLowerCase();
list.querySelectorAll('.xf-addfield').forEach(b => { b.hidden = !!q && !b.dataset.name.includes(q); });
};
addWrap.appendChild(list);
menu.appendChild(addWrap);
const actions = el('div', 'xf-actions');
const reset = el('button', 'xf-cancel', 'Reset to default'); reset.type = 'button';
reset.onclick = () => { resetCustomColumns(); renderFieldEditor(); };
const done = el('button', 'xf-ok', 'Done'); done.type = 'button'; done.onclick = closeMenu;
actions.appendChild(reset); actions.appendChild(done);
menu.appendChild(actions);
}
// ------------------------------------------------------------------------------------
// Hook into the dashboard
// ------------------------------------------------------------------------------------
function wrap(name, after) {
const orig = window[name];
if (typeof orig !== 'function' || orig.__xf) return;
const wrapped = function () {
const result = orig.apply(this, arguments);
try { after.apply(null, arguments); } catch (e) { console.error('[excel-filter]', e); }
return result;
};
wrapped.__xf = true;
window[name] = wrapped;
}
function init() {
wrap('applyFilters', g => afterFilterPass(document.getElementById(`table-${g}`)));
wrap('filterSoftwareTab', () => afterFilterPass(document.getElementById('table-software')));
wrap('filterCustomTab', () => afterFilterPass(document.getElementById('table-custom')));
wrap('refreshStackTable', id => afterFilterPass(document.getElementById(`table-${id}`)));
wrap('resetCustomFilters', () => { delete filters.custom; filterCustomTab(); });
// Custom View's own filter icon opens the Excel-style menu instead
window.openColFilter = (colId, idx, anchor) => openMenu(document.getElementById('table-custom'), idx, anchor);
// Mark tables and add header buttons as they appear (before any filter pass runs)
let queued = false;
const scan = () => {
queued = false;
document.querySelectorAll('table.master-table').forEach(t => {
const ad = adapter(t);
if (!ad) return;
t.classList.add('xf-table');
decorate(t, ad);
});
};
const schedule = () => { if (!queued) { queued = true; requestAnimationFrame(scan); } };
new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
if (cardsQuery.addEventListener) cardsQuery.addEventListener('change', schedule); else cardsQuery.addListener(schedule);
schedule();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();
+1
View File
@@ -353,6 +353,7 @@
</style> </style>
<link rel="stylesheet" href="/layout.css"> <link rel="stylesheet" href="/layout.css">
<script src="/layout.js"></script> <script src="/layout.js"></script>
<script src="/excel-filter.js" defer></script>
</head> </head>
<body> <body>
+152 -71
View File
@@ -1,6 +1,8 @@
/* /*
* Layout additions for the dashboard, loaded after the page's own <style> block: * Layout additions for the dashboard, loaded after the page's own <style> block:
* - sticky status-group headers (all sizes) and sticky column headers (desktop) * - sticky status-group headers and table headers
* - Custom View filling the page on desktop
* - Excel-style filter menus, filter chip bar and card-field editor (excel-filter.js)
* - card view for tables on phones and tablets * - card view for tables on phones and tablets
* - small-screen fixes for forms, docs and dialogs * - small-screen fixes for forms, docs and dialogs
* layout.js measures the sticky offsets and labels card cells. * layout.js measures the sticky offsets and labels card cells.
@@ -10,28 +12,35 @@
* Sticky headers * Sticky headers
* *
* A status group's header ("Not Started (37)") sticks under the top nav while you scroll * A status group's header ("Not Started (37)") sticks under the top nav while you scroll
* through that group, then hands over to the next group's header. Beneath it the table's * through that group, then hands over to the next group's header. On desktop the column
* header (column names + filters on desktop, the filter panel on phones) sticks as one * header sticks beneath it. layout.js sets --nav-h and, per table, --sticky-top
* block, so the rows inside it can never drift apart. layout.js sets --nav-h and, per * (nav + group header).
* table, --sticky-top (nav + group header).
* ------------------------------------------------------------------------------------- */ * ------------------------------------------------------------------------------------- */
.group-header { position: sticky; top: var(--nav-h, 60px); z-index: 30; } .group-header { position: sticky; top: var(--nav-h, 60px); z-index: 30; }
/* The old filter row (text boxes and fixed lists) is replaced by Excel-style column menus */
table.xf-table thead tr.filter-row { display: none !important; }
.master-table:not(#table-custom) thead { position: sticky; top: var(--sticky-top, 112px); z-index: 20; } .master-table:not(#table-custom) thead { position: sticky; top: var(--sticky-top, 112px); z-index: 20; }
.master-table:not(#table-custom) thead tr.filter-row th {
/* the original header tint, but opaque so rows don't show through */
background: linear-gradient(rgba(0,0,0,0.2), rgba(0,0,0,0.2)), var(--bg-card);
}
@media (min-width: 1101px) { @media (min-width: 1101px) {
/* An overflowing wrapper would capture the sticky header; these tables never need to /* An overflowing wrapper would capture the sticky header; these tables never need to
scroll sideways at desktop widths (they're fixed-layout, 100% wide) */ scroll sideways at desktop widths (they're fixed-layout, 100% wide) */
#masterList .table-wrapper, #software .table-wrapper, .stack-box.table-wrapper { overflow: visible !important; } #masterList .table-wrapper, #software .table-wrapper, .stack-box.table-wrapper { overflow: visible !important; }
/* Custom View scrolls inside its own box (it can be wider than the page), so its /* Collapsed table borders stay behind when a header sticks, leaving a hairline gap
group header stays put and its column header sticks to the top of that box */ that rows show through; separated borders move with their cells */
#wrapper-custom .group-header { position: static; } .master-table { border-collapse: separate; border-spacing: 0; }
#scroll-custom thead th { position: sticky; top: 0; z-index: 20; }
/* Custom View fills the page like the other views. It still scrolls sideways when its
columns are wider than the page; its header is pinned by layout.js (--pin-y) since a
sideways-scrolling box can't hold a sticky header */
#custom, #wrapper-custom { overflow: visible !important; }
#scroll-custom { max-height: none !important; overflow-x: auto; overflow-y: hidden; }
#table-custom thead th {
position: relative !important; top: auto !important; z-index: 20;
transform: translateY(var(--pin-y, 0px));
}
} }
/* --------------------------------------------------------------------------------------- /* ---------------------------------------------------------------------------------------
@@ -57,9 +66,9 @@ body { padding-bottom: var(--dock-h, 40px) !important; }
* An 11-column table can't be read on a phone or an iPad in portrait, and sideways * An 11-column table can't be read on a phone or an iPad in portrait, and sideways
* scrolling only ever shows a few columns. Below 1100px (where the nav also collapses) * scrolling only ever shows a few columns. Below 1100px (where the nav also collapses)
* each row becomes a card: the device name as its title and every other column as a * each row becomes a card: the device name as its title and every other column as a
* "label: value" line. mobile.js copies the column names onto the cells. Cards flow one * "label: value" line. layout.js copies the column names onto the cells. Cards flow one
* per row on phones and two or more on tablets. The column filter row becomes a filter * per row on phones and two or more on tablets. A sticky bar of column chips above the
* panel above the cards. * cards opens the same Excel-style filter menus as the desktop headers.
* ------------------------------------------------------------------------------------- */ * ------------------------------------------------------------------------------------- */
@media (max-width: 1100px) { @media (max-width: 1100px) {
.table-wrapper { overflow-x: visible !important; } .table-wrapper { overflow-x: visible !important; }
@@ -73,39 +82,13 @@ body { padding-bottom: var(--dock-h, 40px) !important; }
table.master-table thead tr.card-label-row, table.master-table thead tr.card-label-row,
table.master-table thead tr.card-aux-row { display: none; } table.master-table thead tr.card-aux-row { display: none; }
/* Filters: a wrapping panel of labeled controls */ /* Column names live on the cards; the header is replaced by the filter chip bar */
table.master-table thead tr.filter-row { table.xf-table thead { display: none !important; }
display: flex; flex-wrap: wrap; gap: 8px 10px; padding: 10px 12px;
/* opaque: the panel sticks over the cards while scrolling */
background: linear-gradient(rgba(0,0,0,0.15), rgba(0,0,0,0.15)), var(--bg-card);
border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 8px rgba(0,0,0,0.3);
}
table.master-table thead tr.filter-row th {
display: block; flex: 1 1 150px; min-width: 0; width: auto !important;
padding: 0; border: 0; background: transparent;
}
table.master-table thead tr.filter-row th:empty,
table.master-table thead tr.filter-row th[data-label=""] { display: none; }
table.master-table thead tr.filter-row th::before {
content: attr(data-label); display: block; margin-bottom: 3px;
font-size: 0.78em; font-weight: 600; color: var(--text-muted);
}
/* Custom View: cards don't need the sideways-scrolling box, so it scrolls with the /* Custom View scrolls with the page like the other views */
page like the other tables and its header strip sticks under the group header */ #custom, #wrapper-custom, #scroll-custom { overflow: visible !important; max-height: none !important; }
#wrapper-custom, #scroll-custom { overflow: visible !important; max-height: none !important; } /* Desktop column tools don't apply to cards ("Card fields" replaces them) */
#table-custom thead { position: sticky; top: var(--sticky-top, 112px); z-index: 20; } #custom button[onclick*="shrinkCustomColumns"], #custom [onclick*="toggleAddColMenu"] { display: none !important; }
/* Custom View keeps its header (sort / filter / remove buttons) as a strip of chips */
#table-custom thead tr.card-label-row {
display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 12px;
background: var(--table-header-bg);
}
#table-custom thead tr.card-label-row th {
display: flex; align-items: center; gap: 4px; width: auto !important; min-width: 0 !important;
padding: 4px 8px; border: 1px solid rgba(255,255,255,0.2); border-radius: 999px;
position: static; white-space: nowrap;
}
/* Rows -> cards */ /* Rows -> cards */
table.master-table tbody { table.master-table tbody {
@@ -146,30 +129,8 @@ body { padding-bottom: var(--dock-h, 40px) !important; }
/* "No devices" style rows span the full card */ /* "No devices" style rows span the full card */
table.master-table tbody td[colspan] { display: block; text-align: center; border: 0; padding: 6px 0; } table.master-table tbody td[colspan] { display: block; text-align: center; border: 0; padding: 6px 0; }
/* Filters fold away behind a button (added by mobile.js) */
.card-filter-toggle {
display: inline-flex; align-items: center; gap: 6px; margin: 10px 12px 0;
background: transparent; color: var(--text-main); border: 1px solid var(--border-color);
border-radius: 6px; padding: 6px 12px; font: inherit; font-size: 0.85em; cursor: pointer;
}
.card-filter-toggle .count {
background: #3498db; color: #fff; border-radius: 999px; padding: 0 7px; font-size: 0.85em;
}
table.master-table:not(.show-filters) thead tr.filter-row { display: none; }
table.master-table.show-filters thead tr.filter-row { margin-top: 10px; }
}
@media (min-width: 1101px) {
.card-filter-toggle { display: none; }
}
@media (max-width: 600px) {
/* Compact filter panel on phones: three per row, shorter controls */
table.master-table thead tr.filter-row { gap: 6px 8px; padding: 8px 10px; }
table.master-table thead tr.filter-row th { flex: 1 1 96px; }
table.master-table thead tr.filter-row th::before { font-size: 0.7em; margin-bottom: 2px; }
table.master-table thead tr.filter-row input[type="text"],
table.master-table thead tr.filter-row .select-box { height: 32px; padding: 3px 6px; }
table.master-table thead tr.filter-row .select-box { font-size: 13px; }
} }
@media (max-width: 420px) { @media (max-width: 420px) {
table.master-table tbody td { padding-left: 7.8em; } table.master-table tbody td { padding-left: 7.8em; }
table.master-table tbody td::before { width: 7.2em; } table.master-table tbody td::before { width: 7.2em; }
@@ -238,3 +199,123 @@ body { padding-bottom: var(--dock-h, 40px) !important; }
.group-header { font-size: 0.98em; padding: 8px 10px; } .group-header { font-size: 0.98em; padding: 8px 10px; }
.modal-content { margin: 66px 6px 6px !important; max-height: calc(100dvh - 72px) !important; } .modal-content { margin: 66px 6px 6px !important; max-height: calc(100dvh - 72px) !important; }
} }
/* ---------------------------------------------------------------------------------------
* Excel-style filters (excel-filter.js)
* ------------------------------------------------------------------------------------- */
/* Header dropdown button: takes the old sort icon's spot at the right of the header, next
to the copy-column icon (sorting is in the menu, and the button shows the sort arrow) */
table.xf-table thead th > .sort-icon { display: none !important; }
table.xf-table thead th > .xf-btn { position: absolute; right: 22px; top: 50%; transform: translateY(-50%); margin: 0; }
table.xf-table thead th:has(> .xf-btn):not(.sortable-th) { position: relative; padding-right: 40px !important; }
.xf-btn {
display: inline-flex; align-items: center; justify-content: center; vertical-align: middle;
width: 17px; height: 17px; margin-left: 4px; padding: 0; cursor: pointer;
border: 1px solid rgba(255,255,255,0.35); border-radius: 4px;
background: rgba(255,255,255,0.08); color: inherit;
}
.xf-btn:hover { background: rgba(255,255,255,0.22); }
.xf-btn svg { width: 10px; height: 10px; }
.xf-btn.xf-on { background: #f1c40f; border-color: #f1c40f; color: #1f2937; }
.xf-btn.xf-sorted { background: rgba(255,255,255,0.28); border-color: rgba(255,255,255,0.6); }
.col-filter-btn.xf-on { color: #f1c40f !important; opacity: 1 !important; }
/* Dropdown menu (desktop) / bottom sheet (phones) */
.xf-menu {
position: fixed; z-index: 2600; width: 290px; display: flex; flex-direction: column;
padding: 6px 0; overflow: hidden; font-size: 14px; text-align: left;
background: var(--bg-card); color: var(--text-main);
border: 1px solid var(--border-color); border-radius: 8px; box-shadow: 0 12px 32px rgba(0,0,0,0.5);
}
.xf-head { display: none; }
.xf-item {
display: flex; align-items: center; gap: 10px; padding: 7px 14px; cursor: pointer;
background: none; border: 0; color: inherit; font: inherit; text-align: left;
}
.xf-item:hover:not(:disabled) { background: var(--hover-bg); }
.xf-item:disabled { opacity: 0.45; cursor: default; }
.xf-item svg { width: 14px; height: 14px; flex: none; }
.xf-item.xf-checked { color: #3498db; font-weight: 700; }
.xf-sep { height: 1px; margin: 4px 0; background: var(--border-light); }
.xf-search {
margin: 6px 12px; padding: 7px 9px; font: inherit; color: var(--text-main);
background: var(--input-bg); border: 1px solid var(--border-color); border-radius: 5px;
}
.xf-list {
flex: 1 1 auto; min-height: 80px; max-height: 260px; overflow-y: auto;
margin: 0 12px; padding: 4px 0; border: 1px solid var(--border-light); border-radius: 5px;
}
.xf-opt { display: flex; align-items: center; gap: 8px; padding: 4px 10px; cursor: pointer; white-space: nowrap; }
.xf-opt span { overflow: hidden; text-overflow: ellipsis; }
.xf-opt:hover { background: var(--hover-bg); }
.xf-opt[hidden] { display: none; }
.xf-all { font-weight: 600; border-bottom: 1px solid var(--border-light); }
.xf-empty { padding: 8px 10px; color: var(--text-muted); }
.xf-actions { display: flex; justify-content: flex-end; gap: 8px; padding: 10px 12px 6px; }
.xf-actions button {
min-width: 76px; padding: 6px 12px; cursor: pointer; font: inherit;
background: transparent; color: inherit; border: 1px solid var(--border-color); border-radius: 5px;
}
.xf-actions .xf-ok { background: #3498db; border-color: #3498db; color: #fff; font-weight: 600; }
.xf-actions .xf-ok:disabled { opacity: 0.5; cursor: default; }
.xf-backdrop { position: fixed; inset: 0; z-index: 2590; background: rgba(0,0,0,0.5); }
.xf-menu.xf-sheet {
left: 0 !important; right: 0; bottom: 0; top: auto !important; margin: 0 auto;
width: auto; max-width: 560px; max-height: 82vh !important; max-height: 82dvh !important;
border-radius: 14px 14px 0 0; padding-bottom: calc(6px + env(safe-area-inset-bottom));
font-size: 15px;
}
.xf-sheet .xf-head {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 16px 10px; margin-bottom: 4px; font-weight: 700; font-size: 1.05em;
border-bottom: 1px solid var(--border-light);
}
.xf-x { background: none; border: 0; color: inherit; font-size: 1.7em; line-height: 1; cursor: pointer; padding: 0 4px; }
.xf-sheet .xf-item { padding: 11px 16px; }
.xf-sheet .xf-opt { padding: 9px 12px; }
.xf-sheet .xf-list { max-height: none; }
.xf-sheet input[type="checkbox"] { width: 18px; height: 18px; }
.xf-sheet .xf-actions button { flex: 1; padding: 11px; }
/* Filter chip bar (cards) */
.xf-bar { display: none; }
@media (max-width: 1100px) {
.xf-bar {
display: flex; gap: 6px; padding: 8px 10px; overflow-x: auto; scrollbar-width: none;
position: sticky; top: var(--sticky-top, 112px); z-index: 25;
background: linear-gradient(rgba(0,0,0,0.15), rgba(0,0,0,0.15)), var(--bg-card);
border-bottom: 1px solid var(--border-color); box-shadow: 0 4px 8px rgba(0,0,0,0.25);
}
.xf-bar::-webkit-scrollbar { display: none; }
}
.xf-chip {
display: inline-flex; align-items: center; gap: 5px; flex: none; padding: 6px 11px; cursor: pointer;
font: inherit; font-size: 0.82em; white-space: nowrap;
background: var(--bg-body); color: var(--text-main); border: 1px solid var(--border-color); border-radius: 999px;
}
.xf-chip svg { width: 10px; height: 10px; }
.xf-chip.xf-on { background: #f1c40f; border-color: #f1c40f; color: #1f2937; font-weight: 600; }
.xf-chip.xf-clear { color: #e74c3c; border-color: #e74c3c; }
.xf-chip.xf-fields { background: #3498db; border-color: #3498db; color: #fff; font-weight: 600; }
/* Custom View card-field editor */
.xf-hint { padding: 0 16px 8px; font-size: 0.85em; color: var(--text-muted); }
.xf-fields { flex: 0 0 auto; max-height: 34vh; overflow-y: auto; padding: 0 12px; } /* never squeezed by the add list */
.xf-field { display: flex; align-items: center; gap: 8px; padding: 6px 4px; border-bottom: 1px solid var(--border-light); }
.xf-field-name { flex: 1; min-width: 0; font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.xf-badge { font-size: 0.7em; padding: 2px 8px; border-radius: 999px; background: #3498db; color: #fff; }
.xf-mini {
width: 36px; height: 36px; flex: none; cursor: pointer; font-size: 1.1em;
background: transparent; color: inherit; border: 1px solid var(--border-color); border-radius: 6px;
}
.xf-mini:disabled { opacity: 0.3; cursor: default; }
.xf-add { display: flex; flex-direction: column; flex: 1 1 auto; min-height: 0; margin-top: 8px; }
.xf-fields-sheet .xf-list { flex: 1 1 auto; min-height: 110px; }
.xf-fields-sheet .xf-add .xf-search { flex: none; }
.xf-group { padding: 10px 10px 4px; font-size: 0.75em; font-weight: 700; letter-spacing: 0.5px; text-transform: uppercase; color: var(--text-muted); }
.xf-addfield { display: block; width: 100%; padding: 8px 10px; text-align: left; cursor: pointer; background: none; border: 0; color: #3498db; font: inherit; }
.xf-addfield:hover { background: var(--hover-bg); }
.xf-addfield[hidden] { display: none; }
.xf-clear-all { white-space: nowrap; color: #f1c40f !important; border-color: rgba(241,196,15,0.6) !important; }
.xf-clear-all svg { width: 14px; height: 14px; flex: none; }
+19 -28
View File
@@ -45,33 +45,6 @@
thead.querySelectorAll('tr.filter-row').forEach(apply); thead.querySelectorAll('tr.filter-row').forEach(apply);
for (const body of table.tBodies) for (const row of body.rows) apply(row); 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 --------------------------------------------------------------- // --- Sticky offsets ---------------------------------------------------------------
@@ -99,8 +72,25 @@
const gh = groupHeaderFor(table); const gh = groupHeaderFor(table);
const ghH = gh && getComputedStyle(gh).position === 'sticky' ? gh.getBoundingClientRect().height : 0; const ghH = gh && getComputedStyle(gh).position === 'sticky' ? gh.getBoundingClientRect().height : 0;
// 1px overlap tucks the table header under the group header: no hairline gap // 1px overlap tucks the table header under the group header: no hairline gap
setVar(table, '--sticky-top', Math.max(0, navH + ghH - 1) + 'px'); 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; let queued = false;
@@ -124,6 +114,7 @@
// Groups and pages are shown by toggling display (not a DOM change), so also // 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. // re-measure as the user scrolls; writes only happen when a value changes.
window.addEventListener('scroll', schedule, { passive: true }); 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 // The terminal dock opens, closes and resizes without a page resize
if (window.ResizeObserver) { if (window.ResizeObserver) {
const watchDock = () => { const watchDock = () => {