Custom View: native sticky header, one-line Excel-style header; fixes

- Custom View header now uses the browser's sticky positioning like the
  other tables, so it no longer lags or bounces while scrolling. Because a
  sideways-scrolling box prevents sticky headers, the table is clipped and
  shifted sideways by a scrollbar pinned to the bottom of the window
  (shown only when the columns are wider than the page; trackpad,
  shift+wheel and touch swipes drive it too)
- Custom View header on one line like the other tables: drag grip, name,
  the combined sort/filter dropdown and copy icon (remove on hover)
- Workbench stack text boxes size to their contents on load and when the
  page is shown, not only while typing
- Demo sandbox waits for the worker's first sweep after a container
  restart instead of treating every device as unknown
This commit is contained in:
Johan Sandoval committed 2026-10-06 17:48:54 +00:00
1 parent b216ce43f2
commit 318c59e610
4 files changed
+114 -31

No files matched your search

+10 -2
View File
@@ -555,6 +555,12 @@
const tracked = await visitorTracked();
const applied = s.appliedTracked || tracked;
const snap = await serverSnapshot();
if (!snap.rows.length) { // container just started: nothing to sweep against yet
w([['[Waiting for the first worker sweep to finish...]', 'DarkGray']]);
Sim.again = kind;
await sleep(5000);
return;
}
const before = new Set(Object.keys(parseRoster(applied).tagMap));
const { rows } = buildRows(tracked, snap.rows, snap.adv);
const { tagMap } = parseRoster(tracked);
@@ -700,14 +706,16 @@
'GET Data.json': async () => {
const s = Store.state;
const snap = await serverSnapshot();
if (!s.appliedTracked) return jsonResponse(snap.data);
// Until the worker's first sweep there are no rows to merge with: pass through so
// the dashboard shows its "Waiting for Worker..." placeholders
if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.data);
const { rows } = buildRows(s.appliedTracked, snap.rows, snap.adv);
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), Devices: rows });
},
'GET Advanced-Cache.json': async () => {
const s = Store.state;
const snap = await serverSnapshot();
if (!s.appliedTracked) return jsonResponse(snap.adv);
if (!s.appliedTracked || !snap.rows.length) return jsonResponse(snap.adv);
const { adv } = buildRows(s.appliedTracked, snap.rows, snap.adv);
for (const [k, val] of Object.entries(adv)) if (val) snap.adv[k] = val;
return jsonResponse(snap.adv);
+5 -9
View File
@@ -199,23 +199,19 @@
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 {
// Header buttons (Custom View's goes in its icon group, replacing its filter + sort icons)
{
cols.forEach(c => {
if (!c.filterable && !c.sortable) return;
let btn = c.th.querySelector(':scope > .xf-btn');
const holder = ad.custom ? (c.th.querySelector('.th-icons') || c.th) : c.th;
let btn = holder.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);
holder.appendChild(btn);
}
// Like Excel's button: funnel when filtered, an arrow when sorted, otherwise a caret
const on = activeKeys.has(ad.colKey(c.idx));
+32 -7
View File
@@ -32,15 +32,40 @@ table.xf-table thead tr.filter-row { display: none !important; }
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 View fills the page like the other views, with a native sticky header (same
as the other tables, so it can't lag or bounce). A sideways-scrolling box would stop
the header sticking, so when the columns are wider than the page the table is
clipped and scrolled sideways by layout.js through a scrollbar pinned to the bottom
of the window (trackpad, shift+wheel and touch swipes work too) */
#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));
#scroll-custom { max-height: none !important; overflow-x: clip !important; overflow-y: visible !important; }
#table-custom thead th { position: sticky !important; top: var(--sticky-top, 112px) !important; z-index: 20; }
.custom-hscroll {
position: sticky; bottom: var(--dock-h, 40px); z-index: 15; height: 16px;
overflow-x: auto; overflow-y: hidden;
background: var(--bg-card); border-top: 1px solid var(--border-light); border-radius: 0 0 8px 8px;
}
.custom-hscroll > div { height: 1px; }
/* Custom View header on one line like the other tables: drag grip, name, then the
Excel-style button (sort + filter) and copy icon; remove (x) appears on hover */
#table-custom .custom-th .col-grip-row {
position: absolute; left: 0; top: 0; bottom: 0; width: 18px; height: auto;
border: 0; background: transparent;
}
#table-custom .custom-th .col-grip { color: rgba(255,255,255,0.45); }
#table-custom .custom-th:hover .col-grip { color: rgba(255,255,255,0.9); }
#table-custom .custom-th .col-grip svg { width: 12px; height: 12px; }
#table-custom .custom-th .th-icons {
position: absolute; right: 4px; top: 50%; transform: translateY(-50%);
padding: 0; gap: 5px; overflow: visible;
}
#table-custom .custom-th .th-icons .col-filter-btn,
#table-custom .custom-th .th-icons .sort-icon-inline { display: none !important; }
#table-custom .custom-th .th-icons .col-remove { order: 0; }
#table-custom .custom-th .th-icons .xf-btn { order: 1; margin: 0; }
#table-custom .custom-th .th-icons .col-copy { order: 2; }
#table-custom .custom-th .col-label-row { padding: 9px 66px 9px 20px; }
}
/* ---------------------------------------------------------------------------------------
+67 -13
View File
@@ -76,27 +76,77 @@
setVar(table, '--sticky-top', top);
if (table.parentElement) setVar(table.parentElement, '--sticky-top', top); // for the filter chip bar
});
pinCustomHeader();
setupCustomHScroll();
}
// 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() {
// Custom View can be wider than the page. A sideways-scrolling box would stop its header
// from sticking, so the box clips instead (overflow-x: clip) and the table is shifted
// sideways by a scrollbar pinned to the bottom of the window. Wheel, shift+wheel and
// touch swipes over the table drive the same scrollbar.
function setupCustomHScroll() {
const box = document.getElementById('scroll-custom');
const host = document.getElementById('custom-table-host');
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');
if (!box || !host || !table) return;
let bar = document.getElementById('custom-hscroll');
if (!bar) {
bar = document.createElement('div');
bar.id = 'custom-hscroll';
bar.className = 'custom-hscroll no-copy';
bar.appendChild(document.createElement('div'));
box.insertAdjacentElement('afterend', bar);
const applyX = () => { host.style.transform = bar.scrollLeft ? `translateX(${-bar.scrollLeft}px)` : ''; };
bar.addEventListener('scroll', applyX, { passive: true });
box.addEventListener('wheel', e => {
if (bar.hidden) return;
const dx = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0);
if (!dx) return;
e.preventDefault();
bar.scrollLeft += dx;
}, { passive: false });
let start = null;
box.addEventListener('touchstart', e => {
if (bar.hidden || e.touches.length !== 1) { start = null; return; }
start = { x: e.touches[0].clientX, y: e.touches[0].clientY, left: bar.scrollLeft, lock: null };
}, { passive: true });
box.addEventListener('touchmove', e => {
if (!start) return;
const dx = e.touches[0].clientX - start.x, dy = e.touches[0].clientY - start.y;
if (!start.lock && Math.hypot(dx, dy) > 8) start.lock = Math.abs(dx) > Math.abs(dy) ? 'x' : 'y';
if (start.lock === 'x') { e.preventDefault(); bar.scrollLeft = start.left - dx; }
}, { passive: false });
box.addEventListener('touchend', () => { start = null; }, { passive: true });
}
if (cardsQuery.matches || !table.offsetParent) {
bar.hidden = true;
if (host.style.transform) host.style.transform = '';
return;
}
const full = Math.ceil(table.getBoundingClientRect().width);
const view = box.clientWidth;
const inner = bar.firstChild;
if (inner.style.width !== full + 'px') inner.style.width = full + 'px';
const hide = full <= view + 1;
if (bar.hidden !== hide) bar.hidden = hide;
if (hide && bar.scrollLeft) { bar.scrollLeft = 0; host.style.transform = ''; }
}
// Auto-sizing text boxes (Workbench stacks) only grow while typing in the original;
// size them to their contents whenever they're shown or re-rendered too.
function autosizeTextareas() {
document.querySelectorAll('textarea.auto-resize').forEach(t => {
if (!t.offsetParent || document.activeElement === t) return;
if (Math.abs(t.scrollHeight - t.clientHeight) <= 2) return;
t.style.height = 'auto';
t.style.height = t.scrollHeight + 'px';
});
}
let queued = false;
function run() {
queued = false;
updateSticky();
autosizeTextareas();
if (!cardsQuery.matches) return;
document.querySelectorAll('table.master-table').forEach(labelTable);
}
@@ -114,7 +164,11 @@
// 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
// Page switches show and hide pages without changing the DOM
if (typeof window.switchPage === 'function') {
const orig = window.switchPage;
window.switchPage = function () { const r = orig.apply(this, arguments); schedule(); return r; };
}
// The terminal dock opens, closes and resizes without a page resize
if (window.ResizeObserver) {
const watchDock = () => {