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:
1 parent
b216ce43f2
commit
318c59e610
4 files changed
+114
-31
No files matched your search
+10
-2
@@ -555,6 +555,12 @@
|
|||||||
const tracked = await visitorTracked();
|
const tracked = await visitorTracked();
|
||||||
const applied = s.appliedTracked || tracked;
|
const applied = s.appliedTracked || tracked;
|
||||||
const snap = await serverSnapshot();
|
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 before = new Set(Object.keys(parseRoster(applied).tagMap));
|
||||||
const { rows } = buildRows(tracked, snap.rows, snap.adv);
|
const { rows } = buildRows(tracked, snap.rows, snap.adv);
|
||||||
const { tagMap } = parseRoster(tracked);
|
const { tagMap } = parseRoster(tracked);
|
||||||
@@ -700,14 +706,16 @@
|
|||||||
'GET Data.json': async () => {
|
'GET Data.json': async () => {
|
||||||
const s = Store.state;
|
const s = Store.state;
|
||||||
const snap = await serverSnapshot();
|
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);
|
const { rows } = buildRows(s.appliedTracked, snap.rows, snap.adv);
|
||||||
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), Devices: rows });
|
return jsonResponse({ LastUpdated: snap.data.LastUpdated || psStamp(), Devices: rows });
|
||||||
},
|
},
|
||||||
'GET Advanced-Cache.json': async () => {
|
'GET Advanced-Cache.json': async () => {
|
||||||
const s = Store.state;
|
const s = Store.state;
|
||||||
const snap = await serverSnapshot();
|
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);
|
const { adv } = buildRows(s.appliedTracked, snap.rows, snap.adv);
|
||||||
for (const [k, val] of Object.entries(adv)) if (val) snap.adv[k] = val;
|
for (const [k, val] of Object.entries(adv)) if (val) snap.adv[k] = val;
|
||||||
return jsonResponse(snap.adv);
|
return jsonResponse(snap.adv);
|
||||||
|
|||||||
+5
-9
@@ -199,23 +199,19 @@
|
|||||||
const active = activeFilters(ad, cols);
|
const active = activeFilters(ad, cols);
|
||||||
const activeKeys = new Set(active.map(c => ad.colKey(c.idx)));
|
const activeKeys = new Set(active.map(c => ad.colKey(c.idx)));
|
||||||
|
|
||||||
// Header buttons (Custom View has its own filter icon, rerouted to our menu)
|
// Header buttons (Custom View's goes in its icon group, replacing its filter + sort icons)
|
||||||
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 => {
|
cols.forEach(c => {
|
||||||
if (!c.filterable && !c.sortable) return;
|
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) {
|
if (!btn) {
|
||||||
btn = document.createElement('button');
|
btn = document.createElement('button');
|
||||||
btn.type = 'button';
|
btn.type = 'button';
|
||||||
btn.className = 'xf-btn no-copy';
|
btn.className = 'xf-btn no-copy';
|
||||||
btn.title = 'Sort and filter';
|
btn.title = 'Sort and filter';
|
||||||
btn.addEventListener('click', e => { e.stopPropagation(); openMenu(table, c.idx, btn); });
|
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
|
// Like Excel's button: funnel when filtered, an arrow when sorted, otherwise a caret
|
||||||
const on = activeKeys.has(ad.colKey(c.idx));
|
const on = activeKeys.has(ad.colKey(c.idx));
|
||||||
|
|||||||
+32
-7
@@ -32,15 +32,40 @@ table.xf-table thead tr.filter-row { display: none !important; }
|
|||||||
that rows show through; separated borders move with their cells */
|
that rows show through; separated borders move with their cells */
|
||||||
.master-table { border-collapse: separate; border-spacing: 0; }
|
.master-table { border-collapse: separate; border-spacing: 0; }
|
||||||
|
|
||||||
/* Custom View fills the page like the other views. It still scrolls sideways when its
|
/* Custom View fills the page like the other views, with a native sticky header (same
|
||||||
columns are wider than the page; its header is pinned by layout.js (--pin-y) since a
|
as the other tables, so it can't lag or bounce). A sideways-scrolling box would stop
|
||||||
sideways-scrolling box can't hold a sticky header */
|
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; }
|
#custom, #wrapper-custom { overflow: visible !important; }
|
||||||
#scroll-custom { max-height: none !important; overflow-x: auto; overflow-y: hidden; }
|
#scroll-custom { max-height: none !important; overflow-x: clip !important; overflow-y: visible !important; }
|
||||||
#table-custom thead th {
|
#table-custom thead th { position: sticky !important; top: var(--sticky-top, 112px) !important; z-index: 20; }
|
||||||
position: relative !important; top: auto !important; z-index: 20;
|
.custom-hscroll {
|
||||||
transform: translateY(var(--pin-y, 0px));
|
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
@@ -76,27 +76,77 @@
|
|||||||
setVar(table, '--sticky-top', top);
|
setVar(table, '--sticky-top', top);
|
||||||
if (table.parentElement) setVar(table.parentElement, '--sticky-top', top); // for the filter chip bar
|
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
|
// Custom View can be wider than the page. A sideways-scrolling box would stop its header
|
||||||
// sideways-scrolling box can't contain a position:sticky header. Instead its header is
|
// from sticking, so the box clips instead (overflow-x: clip) and the table is shifted
|
||||||
// shifted down by exactly the amount needed to stay pinned under the group header.
|
// sideways by a scrollbar pinned to the bottom of the window. Wheel, shift+wheel and
|
||||||
function pinCustomHeader() {
|
// 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');
|
const table = document.getElementById('table-custom');
|
||||||
if (!table) return;
|
if (!box || !host || !table) return;
|
||||||
if (cardsQuery.matches || !table.offsetParent || !table.tHead) { setVar(table, '--pin-y', '0px'); return; }
|
let bar = document.getElementById('custom-hscroll');
|
||||||
const top = parseFloat(table.style.getPropertyValue('--sticky-top')) || 0;
|
if (!bar) {
|
||||||
const r = table.getBoundingClientRect();
|
bar = document.createElement('div');
|
||||||
const headH = table.tHead.getBoundingClientRect().height;
|
bar.id = 'custom-hscroll';
|
||||||
const y = Math.max(0, Math.min(top - r.top, r.height - headH));
|
bar.className = 'custom-hscroll no-copy';
|
||||||
setVar(table, '--pin-y', y + 'px');
|
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;
|
let queued = false;
|
||||||
function run() {
|
function run() {
|
||||||
queued = false;
|
queued = false;
|
||||||
updateSticky();
|
updateSticky();
|
||||||
|
autosizeTextareas();
|
||||||
if (!cardsQuery.matches) return;
|
if (!cardsQuery.matches) return;
|
||||||
document.querySelectorAll('table.master-table').forEach(labelTable);
|
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
|
// 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
|
// 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
|
// The terminal dock opens, closes and resizes without a page resize
|
||||||
if (window.ResizeObserver) {
|
if (window.ResizeObserver) {
|
||||||
const watchDock = () => {
|
const watchDock = () => {
|
||||||
|
|||||||
Reference in new issue
Block a user