Files
usmt-dash/web/jump.js
T
Johan Sandoval b0f700928e Phone Jump button; docs never scroll sideways; presence sweep timing kept
- Jump (phones / tablets): find a device by name and scroll to its card, or
  jump to a section, the top or the bottom of the page.
- Documentation on phones: long paths, links, code and tables wrap; toolbar is
  title, last edited, then the buttons.
- Filter bar's Clear is the only clear button on phones.
- Card fields: search box and add list as wide as the field rows.
- Presence sweep: the demo uses the hourly schedule too, and the last sweep
  time survives a worker restart (a restart used to trigger an extra sweep).
2026-10-06 22:01:43 +00:00

208 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* USMT Dash - "Jump" for phones and tablets
*
* Scrolling through thousands of device cards is slow, so on narrow screens a round Jump
* button floats above the terminal bar on every page. It opens a sheet with:
* - Find a device: type part of a name and pick it; the page scrolls to that card and
* flashes it (nothing is filtered or hidden, unlike the search boxes).
* - Sections: every group on the page (USMT Status groups, Workbench stacks, the Overview
* list, the article list / article) with its count, to jump straight there.
* - Top / Bottom.
*/
(function () {
'use strict';
const narrow = window.matchMedia('(max-width: 1100px)');
let btn = null, sheet = null, backdrop = null;
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined) e.textContent = text; return e; };
const visible = e => !!(e && e.offsetParent !== null && e.getClientRects().length);
const currentPage = () => Array.from(document.querySelectorAll('.page')).find(p => p.style.display !== 'none');
const navH = () => parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--nav-h')) || 60;
// Scroll so the element sits just below everything that sticks to the top
function scrollToEl(target, extra) {
const top = target.getBoundingClientRect().top + window.scrollY - navH() - (extra || 0) - 8;
window.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
}
// Sticky group header + filter bar above a card (the header is a sibling of the table or
// of one of its wrappers)
function stickyAbove(row) {
let h = 0;
const table = row.closest('table');
for (let e = table && table.parentElement, depth = 0; e && depth < 4; e = e.parentElement, depth++) {
const gh = e.querySelector(':scope > .group-header');
if (gh) { if (getComputedStyle(gh).position === 'sticky') h += gh.offsetHeight; break; }
}
const bar = table && table.previousElementSibling;
if (bar && bar.classList.contains('xf-bar')) h += bar.offsetHeight;
return h;
}
function flash(node) {
node.classList.remove('jump-flash');
void node.offsetWidth;
node.classList.add('jump-flash');
setTimeout(() => node.classList.remove('jump-flash'), 2200);
}
// A group header's title: a stack's name box, else its text without the buttons
function headerLabel(gh) {
const input = gh.querySelector('input');
if (input) return input.value.trim();
const c = gh.cloneNode(true);
c.querySelectorAll('button, div, .no-copy').forEach(n => n.remove());
return c.textContent.replace(/\s+/g, ' ').trim();
}
// ---------------------------------------------------------------------------------
// What's on the page
// ---------------------------------------------------------------------------------
function sections(page) {
const out = [];
page.querySelectorAll('.group-header').forEach(gh => {
if (!visible(gh)) return;
const label = headerLabel(gh);
if (label) out.push({ label, node: gh.closest('.table-wrapper, .stack-box, [id^="group-container-"]') || gh });
});
if (page.id === 'docs') {
const list = document.getElementById('docList'), art = document.getElementById('docToolbar');
if (visible(list)) out.push({ label: 'Article list', node: list.parentElement });
if (visible(art)) out.push({ label: 'Article', node: art });
}
if (page.id === 'overview') {
[['ov-issues', 'Issues to clear'], ['ov-grid', 'Status & locations'], ['ov-months', 'Deployment calendar']].forEach(([cls, label]) => {
const n = page.querySelector('.' + cls);
if (visible(n)) out.push({ label, node: n.closest('.ov-card') || n });
});
}
return out;
}
// Rows / cards whose device names match
function findDevices(page, q) {
q = q.trim().toUpperCase();
if (q.length < 2) return [];
const hits = [];
const seen = new Set();
page.querySelectorAll('table.master-table tbody tr').forEach(tr => {
if (hits.length >= 25 || !visible(tr)) return;
// device names are the bold cell text in every table (any naming scheme)
const names = Array.from(tr.querySelectorAll('td b')).map(b => b.textContent.trim().toUpperCase())
.concat(tr.cells[0] ? [tr.cells[0].textContent.trim().toUpperCase()] : []);
const name = names.find(n => n && n.length < 64 && n.includes(q));
if (!name) return;
const group = tr.closest('[id^="group-container-"], .stack-box, .table-wrapper');
const gh = group && group.querySelector('.group-header');
const where = gh ? headerLabel(gh).replace(/\s*\(\d+\)\s*$/, '') : '';
const key = name + '|' + where;
if (seen.has(key)) return;
seen.add(key);
hits.push({ name, where, tr });
});
return hits;
}
// ---------------------------------------------------------------------------------
// Sheet
// ---------------------------------------------------------------------------------
function close() {
if (sheet) sheet.remove();
if (backdrop) backdrop.remove();
sheet = backdrop = null;
}
function open() {
close();
const page = currentPage();
if (!page) return;
backdrop = el('div', 'xf-backdrop'); backdrop.onclick = close;
sheet = el('div', 'xf-menu xf-sheet jump-sheet');
sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Jump to');
const head = el('div', 'xf-head');
head.appendChild(el('span', 'xf-title', 'Jump to'));
const x = el('button', 'xf-x', '×'); x.type = 'button'; x.title = 'Close'; x.onclick = close;
head.appendChild(x);
sheet.appendChild(head);
const hasRows = !!page.querySelector('table.master-table tbody tr');
const results = el('div', 'jump-results');
if (hasRows) {
const search = el('input', 'xf-search jump-search');
search.type = 'search'; search.placeholder = 'Find a device (part of the name)...';
search.setAttribute('autocomplete', 'off'); search.setAttribute('autocapitalize', 'characters');
sheet.appendChild(search);
sheet.appendChild(results);
const show = () => {
results.textContent = '';
const q = search.value;
if (q.trim().length < 2) return;
const hits = findDevices(page, q);
if (!hits.length) { results.appendChild(el('div', 'jump-empty', 'No device on this page matches. (Filters may be hiding it.)')); return; }
hits.forEach(h => {
const b = el('button', 'jump-item'); b.type = 'button';
b.appendChild(el('b', null, h.name));
if (h.where) b.appendChild(el('span', null, h.where));
b.onclick = () => { close(); scrollToEl(h.tr, stickyAbove(h.tr)); setTimeout(() => flash(h.tr), 350); };
results.appendChild(b);
});
};
search.oninput = show;
search.onkeydown = e => { if (e.key === 'Enter') { const f = results.querySelector('.jump-item'); if (f) f.click(); } };
}
const secs = sections(page);
if (secs.length) {
sheet.appendChild(el('div', 'xf-group', 'Sections'));
const list = el('div', 'jump-sections');
secs.forEach(s => {
const b = el('button', 'jump-item jump-sec'); b.type = 'button';
b.textContent = s.label;
b.onclick = () => { close(); scrollToEl(s.node); };
list.appendChild(b);
});
sheet.appendChild(list);
}
const ends = el('div', 'xf-actions');
const top = el('button', 'xf-cancel', '↑ Top'); top.type = 'button';
top.onclick = () => { close(); window.scrollTo({ top: 0, behavior: 'smooth' }); };
const bottom = el('button', 'xf-cancel', '↓ Bottom'); bottom.type = 'button';
bottom.onclick = () => { close(); window.scrollTo({ top: document.documentElement.scrollHeight, behavior: 'smooth' }); };
ends.appendChild(top); ends.appendChild(bottom);
sheet.appendChild(ends);
document.body.appendChild(backdrop);
document.body.appendChild(sheet);
const s = sheet.querySelector('.jump-search');
if (s) setTimeout(() => s.focus(), 50);
}
// ---------------------------------------------------------------------------------
// Button: phones / tablets only, on pages taller than a couple of screens
// ---------------------------------------------------------------------------------
function update() {
if (!btn) return;
const page = currentPage();
const long = document.documentElement.scrollHeight > window.innerHeight * 1.8;
btn.hidden = !narrow.matches || !page || !long;
}
function init() {
btn = el('button', 'jump-btn');
btn.type = 'button'; btn.title = 'Jump to a device or section'; btn.setAttribute('aria-label', 'Jump to a device or section');
btn.innerHTML = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 3v18M12 3 7.5 7.5M12 3l4.5 4.5M12 21l-4.5-4.5M12 21l4.5-4.5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg><span>Jump</span>';
btn.onclick = open;
btn.hidden = true;
document.body.appendChild(btn);
let t = null;
const later = () => { clearTimeout(t); t = setTimeout(update, 250); };
new MutationObserver(later).observe(document.body, { childList: true, subtree: true });
window.addEventListener('resize', later);
if (narrow.addEventListener) narrow.addEventListener('change', () => { close(); update(); });
document.addEventListener('keydown', e => { if (e.key === 'Escape' && sheet) close(); });
update();
}
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
})();