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:
1 parent
d24be4a2d6
commit
b216ce43f2
5 files changed
+788
-99
No files matched your search
@@ -937,6 +937,35 @@
|
||||
let seen = false;
|
||||
try { seen = localStorage.getItem('usmt_demo_seen') === '1'; } catch (e) {}
|
||||
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) {
|
||||
this.modal.classList.toggle('show', show);
|
||||
|
||||
@@ -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">☰ 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' ? ' ↑' : dir === 'desc' ? ' ↓' : '';
|
||||
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();
|
||||
})();
|
||||
@@ -353,6 +353,7 @@
|
||||
</style>
|
||||
<link rel="stylesheet" href="/layout.css">
|
||||
<script src="/layout.js"></script>
|
||||
<script src="/excel-filter.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
+152
-71
@@ -1,6 +1,8 @@
|
||||
/*
|
||||
* 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
|
||||
* - small-screen fixes for forms, docs and dialogs
|
||||
* layout.js measures the sticky offsets and labels card cells.
|
||||
@@ -10,28 +12,35 @@
|
||||
* Sticky headers
|
||||
*
|
||||
* 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
|
||||
* header (column names + filters on desktop, the filter panel on phones) sticks as one
|
||||
* block, so the rows inside it can never drift apart. layout.js sets --nav-h and, per
|
||||
* table, --sticky-top (nav + group header).
|
||||
* through that group, then hands over to the next group's header. On desktop the column
|
||||
* header sticks beneath it. layout.js sets --nav-h and, per table, --sticky-top
|
||||
* (nav + group header).
|
||||
* ------------------------------------------------------------------------------------- */
|
||||
.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 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) {
|
||||
/* An overflowing wrapper would capture the sticky header; these tables never need to
|
||||
scroll sideways at desktop widths (they're fixed-layout, 100% wide) */
|
||||
#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
|
||||
group header stays put and its column header sticks to the top of that box */
|
||||
#wrapper-custom .group-header { position: static; }
|
||||
#scroll-custom thead th { position: sticky; top: 0; z-index: 20; }
|
||||
/* Collapsed table borders stay behind when a header sticks, leaving a hairline gap
|
||||
that rows show through; separated borders move with their cells */
|
||||
.master-table { border-collapse: separate; border-spacing: 0; }
|
||||
|
||||
/* 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
|
||||
* 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
|
||||
* "label: value" line. mobile.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
|
||||
* panel above the cards.
|
||||
* "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. A sticky bar of column chips above the
|
||||
* cards opens the same Excel-style filter menus as the desktop headers.
|
||||
* ------------------------------------------------------------------------------------- */
|
||||
@media (max-width: 1100px) {
|
||||
.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-aux-row { display: none; }
|
||||
|
||||
/* Filters: a wrapping panel of labeled controls */
|
||||
table.master-table thead tr.filter-row {
|
||||
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);
|
||||
}
|
||||
/* Column names live on the cards; the header is replaced by the filter chip bar */
|
||||
table.xf-table thead { display: none !important; }
|
||||
|
||||
/* Custom View: cards don't need the sideways-scrolling box, so it scrolls with the
|
||||
page like the other tables and its header strip sticks under the group header */
|
||||
#wrapper-custom, #scroll-custom { overflow: visible !important; max-height: none !important; }
|
||||
#table-custom thead { position: sticky; top: var(--sticky-top, 112px); z-index: 20; }
|
||||
|
||||
/* 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;
|
||||
}
|
||||
/* Custom View scrolls with the page like the other views */
|
||||
#custom, #wrapper-custom, #scroll-custom { overflow: visible !important; max-height: none !important; }
|
||||
/* Desktop column tools don't apply to cards ("Card fields" replaces them) */
|
||||
#custom button[onclick*="shrinkCustomColumns"], #custom [onclick*="toggleAddColMenu"] { display: none !important; }
|
||||
|
||||
/* Rows -> cards */
|
||||
table.master-table tbody {
|
||||
@@ -146,30 +129,8 @@ body { padding-bottom: var(--dock-h, 40px) !important; }
|
||||
/* "No devices" style rows span the full card */
|
||||
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) {
|
||||
table.master-table tbody td { padding-left: 7.8em; }
|
||||
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; }
|
||||
.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
@@ -45,33 +45,6 @@
|
||||
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 ---------------------------------------------------------------
|
||||
@@ -99,8 +72,25 @@
|
||||
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');
|
||||
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;
|
||||
@@ -124,6 +114,7 @@
|
||||
// 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 = () => {
|
||||
|
||||
Reference in new issue
Block a user