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).
This commit is contained in:
1 parent
9dc2d88726
commit
b0f700928e
6 files changed
+272
-5
No files matched your search
@@ -360,6 +360,7 @@
|
||||
<script src="/overview.js"></script>
|
||||
<script src="/docs.js"></script>
|
||||
<script src="/router.js"></script>
|
||||
<script src="/jump.js"></script>
|
||||
<script src="/excel-filter.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+207
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* 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();
|
||||
})();
|
||||
+50
-2
@@ -213,8 +213,6 @@ body { padding-bottom: var(--dock-h, 40px) !important; }
|
||||
}
|
||||
#docs > div > div:last-child > div:last-child { min-height: 60vh; }
|
||||
#docContent img { max-width: 100%; height: auto; }
|
||||
#docContent table { display: block; overflow-x: auto; }
|
||||
#docContent pre { overflow-x: auto; }
|
||||
#docToolbar { flex-wrap: wrap; gap: 8px; }
|
||||
|
||||
/* Modals fill the screen width and scroll inside themselves */
|
||||
@@ -535,3 +533,53 @@ a.nav-brand:focus-visible { outline: 2px solid #3498db; outline-offset: 3px; }
|
||||
|
||||
/* Details title: when the toggle has to wrap under the name it sits on the left */
|
||||
#detailsTitle.dt-wrapped .dt-toggle { margin-left: 0; }
|
||||
|
||||
/* Card fields: the search box and the add list are as wide as the field rows above them */
|
||||
.xf-fields-sheet .xf-add .xf-search { margin: 0 0 8px; width: 100%; box-sizing: border-box; }
|
||||
.xf-fields-sheet .xf-add .xf-list { margin: 0; width: 100%; box-sizing: border-box; }
|
||||
|
||||
/* Phones / tablets: the filter bar's own Clear button is enough */
|
||||
@media (max-width: 1100px) {
|
||||
.xf-clear-all { display: none !important; }
|
||||
}
|
||||
|
||||
/* Documentation on phones / tablets: nothing scrolls sideways. Long paths, links and code
|
||||
wrap; tables fit the width; the toolbar is title, last edited, then the buttons */
|
||||
@media (max-width: 1100px) {
|
||||
#docContent { overflow-x: hidden; overflow-wrap: anywhere; word-break: break-word; }
|
||||
#docContent pre, #docContent pre code { white-space: pre-wrap !important; word-break: break-all; overflow-x: hidden !important; }
|
||||
#docContent table { display: table !important; table-layout: fixed; width: 100%; overflow: visible !important; }
|
||||
#docContent th, #docContent td { overflow-wrap: anywhere; word-break: break-word; white-space: normal !important; }
|
||||
#docContent img { max-width: 100% !important; height: auto !important; }
|
||||
#docContent a, #docContent code { overflow-wrap: anywhere; word-break: break-all; }
|
||||
#docs, #docs > div, #docs > div > div:last-child > div:last-child { max-width: 100%; overflow-x: hidden; box-sizing: border-box; }
|
||||
#docToolbar { flex-wrap: wrap !important; align-items: center; gap: 8px !important; }
|
||||
#docToolbar #docHeaderInfo { flex: 1 1 100% !important; min-width: 0; }
|
||||
#docToolbar #docDisplayTitle { overflow-wrap: anywhere; }
|
||||
#docToolbar > button, #docToolbar > select { flex: 0 0 auto; margin: 0 !important; }
|
||||
}
|
||||
|
||||
/* Phones / tablets: floating Jump button and its sheet */
|
||||
.jump-btn {
|
||||
position: fixed; right: 14px; bottom: calc(var(--dock-h, 40px) + 14px); z-index: 1800;
|
||||
display: inline-flex; align-items: center; gap: 6px; padding: 10px 15px 10px 12px;
|
||||
background: var(--table-header-bg); color: #fff; border: 1px solid rgba(255,255,255,0.25); border-radius: 999px;
|
||||
font: inherit; font-weight: 700; font-size: 0.9em; cursor: pointer; box-shadow: 0 6px 18px rgba(0,0,0,0.35);
|
||||
}
|
||||
.jump-btn[hidden] { display: none; }
|
||||
.jump-btn svg { width: 18px; height: 18px; }
|
||||
.jump-sheet .jump-search { margin: 8px 12px; font-size: 16px; }
|
||||
.jump-results, .jump-sections { overflow-y: auto; padding: 0 8px; }
|
||||
.jump-results { max-height: 38vh; }
|
||||
.jump-sections { max-height: 30vh; }
|
||||
.jump-item {
|
||||
display: flex; align-items: baseline; justify-content: space-between; gap: 10px; width: 100%;
|
||||
padding: 12px 10px; background: none; border: 0; border-bottom: 1px solid var(--border-light);
|
||||
color: var(--text-main); font: inherit; text-align: left; cursor: pointer;
|
||||
}
|
||||
.jump-item:hover, .jump-item:focus-visible { background: var(--hover-bg); }
|
||||
.jump-item span { font-size: 0.8em; color: var(--text-muted); text-align: right; }
|
||||
.jump-sec { font-weight: 600; }
|
||||
.jump-empty { padding: 12px 10px; color: var(--text-muted); font-size: 0.9em; }
|
||||
@keyframes jump-flash { 0%, 60% { box-shadow: 0 0 0 3px #3498db, 0 0 18px rgba(52,152,219,0.8); } 100% { box-shadow: none; } }
|
||||
tr.jump-flash { animation: jump-flash 2.2s ease-out; }
|
||||
Reference in new issue
Block a user