From 318c59e6108a5b02e0d1dc2b45d994862cae718b Mon Sep 17 00:00:00 2001 From: Johan Sandoval Date: Tue, 6 Oct 2026 17:48:54 +0000 Subject: [PATCH] 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 --- web/demo/demo.js | 12 +++++-- web/excel-filter.js | 14 +++----- web/layout.css | 39 ++++++++++++++++++---- web/layout.js | 80 +++++++++++++++++++++++++++++++++++++-------- 4 files changed, 114 insertions(+), 31 deletions(-) diff --git a/web/demo/demo.js b/web/demo/demo.js index 3bd8a6c..27a9b6d 100644 --- a/web/demo/demo.js +++ b/web/demo/demo.js @@ -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); diff --git a/web/excel-filter.js b/web/excel-filter.js index b519658..f918f1e 100644 --- a/web/excel-filter.js +++ b/web/excel-filter.js @@ -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)); diff --git a/web/layout.css b/web/layout.css index 1ef86c6..d0ae741 100644 --- a/web/layout.css +++ b/web/layout.css @@ -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; } } /* --------------------------------------------------------------------------------------- diff --git a/web/layout.js b/web/layout.js index 1ea3606..6adbe2f 100644 --- a/web/layout.js +++ b/web/layout.js @@ -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 = () => {