Private/Get-DhJsDrawer.ps1
|
function Get-DhJsDrawer { # Fragment of the dashboard runtime JS (see Get-DhJsContent). v2.3.0. return @' /* ========================================================================= SECTIONS + DETAIL DRAWER (v2.3.0) renderSections() is the ONE place that draws the section kinds kv | bars | statusList | checks | table | text | links for the detail drawer, Add-DhKeyValue, Add-DhRecordList and the findings split layout. It is pure (spec in, HTML string out) and escapes every value. dhDrawer is a single right-hand panel appended to <body>, outside every [data-navpath] section, so it survives view changes (applyNav closes it on purpose). No inline on* handlers: bindSectionLinks() binds after the markup is written. ========================================================================= */ var SEC_STATUS = { ok: 'ok', good: 'ok', pass: 'ok', passed: 'ok', healthy: 'ok', warn: 'warn', warning: 'warn', medium: 'warn', danger: 'danger', critical: 'danger', error: 'danger', fail: 'danger', failed: 'danger', high: 'danger', info: 'info', low: 'info', nodata: 'nodata', unknown: 'nodata', skipped: 'nodata' }; /* Status or cell-* class -> one of ok|warn|danger|info|nodata (default nodata). */ function secStatus(v) { var s = String(v == null ? '' : v).toLowerCase().replace(/^cell-/, ''); return SEC_STATUS[s] || (s ? 'nodata' : ''); } function secCls(v) { var k = secStatus(v); return k ? ' dh-st-' + k : ''; } var SEC_LABEL = { ok: 'OK', warn: 'Warning', danger: 'Critical', info: 'Info', nodata: 'Unknown' }; var SEC_URL_OK =/^(https?:|mailto:|tel:|#)/i; function secCell(c) { if (c && typeof c === 'object') { var k = secStatus(c.status); return k ? '<span class="dh-pill dh-st-' + k + '">' + esc(c.text) + '</span>' : esc(c.text); } return esc(c); } function renderSection(sec) { if (!sec || typeof sec !== 'object') return ''; var kind = String(sec.kind || 'text').toLowerCase(); var items = sec.items || []; var h = '<section class="dh-sec dh-sec-' + esc(kind) + '">'; if (sec.title) h += '<h4 class="dh-sec-title">' + esc(sec.title) + '</h4>'; if (kind === 'kv') { var cols = (sec.columns >= 1 && sec.columns <= 6) ? sec.columns : 2; h += '<dl class="dh-kv dh-kv-cols-' + cols + '">' + items.map(function (it) { return '<div class="dh-kv-item' + secCls(it.status || it['class']) + '"><dt class="dh-kv-label">' + esc(it.label) + '</dt><dd class="dh-kv-value">' + (it.value == null || it.value === '' ? '—' : esc(it.value)) + '</dd></div>'; }).join('') + '</dl>'; } else if (kind === 'bars') { var mx = sec.max > 0 ? sec.max : items.reduce(function (m, it) { var v = Number(it.value); return v > m ? v : m; }, 0); h += '<div class="dh-bars">' + items.map(function (it) { var v = Number(it.value), ok = isFinite(v) && it.value !== '' && it.value != null; var pct = (ok && mx > 0) ? Math.max(0, Math.min(100, v / mx * 100)) : 0; var shown = it.display != null ? it.display : (ok ? v + (sec.unit ? ' ' + sec.unit : '') : '—'); return '<div class="dh-bar-row"><span class="dh-bar-label">' + esc(it.label) + '</span>' + '<span class="dh-bar-track"><span class="dh-bar-fill' + secCls(it.status || it['class']) + '" style="width:' + pct.toFixed(1) + '%"></span></span>' + '<span class="dh-bar-val">' + esc(shown) + '</span></div>'; }).join('') + '</div>'; } else if (kind === 'statuslist') { h += '<ul class="dh-statuslist">' + items.map(function (it) { return '<li class="dh-statusrow"><span class="dh-statuslabel">' + esc(it.label) + '</span>' + '<span class="dh-statusval' + secCls(it.status) + '"><i class="dh-dot"></i>' + esc(it.text != null ? it.text : it.status) + '</span></li>'; }).join('') + '</ul>'; } else if (kind === 'checks') { h += '<ul class="dh-checks">' + items.map(function (it) { var st = it.status ? secStatus(it.status) : (it.ok === false ? 'danger' : (it.ok === true ? 'ok' : 'nodata')); var glyph = st === 'ok' ? '✓' : (st === 'danger' ? '✗' : (st === 'warn' ? '!' : '·')); return '<li class="dh-check dh-st-' + st + '"><span class="dh-check-ico" aria-hidden="true">' + glyph + '</span>' + '<span class="dh-check-label">' + esc(it.label) + '</span>' + (it.note ? '<span class="dh-check-note">' + esc(it.note) + '</span>' : '') + '</li>'; }).join('') + '</ul>'; } else if (kind === 'table') { var heads = sec.columns || [], rows = sec.rows || []; var maxRows = sec.maxRows >= 1 ? sec.maxRows : 50; var shownRows = rows.slice(0, maxRows); h += '<div class="dh-tbl-wrap"><table class="dh-tbl"><thead><tr>' + heads.map(function (c) { return '<th>' + esc(c) + '</th>'; }).join('') + '</tr></thead><tbody>' + shownRows.map(function (r) { return '<tr>' + (r || []).map(function (c) { return '<td>' + secCell(c) + '</td>'; }).join('') + '</tr>'; }).join('') + '</tbody></table></div>'; if (rows.length > shownRows.length) h += '<p class="dh-more">+' + (rows.length - shownRows.length) + ' more</p>'; } else if (kind === 'links') { h += '<ul class="dh-links">' + items.map(function (it) { return '<li>' + (SEC_URL_OK.test(String(it.url || '')) ? '<a href="' + esc(it.url) + '" target="_blank" rel="noopener noreferrer">' + esc(it.label || it.url) + '</a>' : '<span class="dh-link-blocked">' + esc(it.label || it.url) + '</span>') + '</li>'; }).join('') + '</ul>'; } else { h += '<p class="dh-text">' + esc(sec.text) + '</p>'; } return h + '</section>'; } /* sections: array of section specs. Returns an HTML string. */ function renderSections(sections) { return (sections || []).map(renderSection).join(''); } /* Add-DhKeyValue block */ function renderKeyValue(container, block) { container.className = 'keyvalue-section'; container.innerHTML = (block.title ? '<h3 class="keyvalue-title">' + esc(block.title) + '</h3>' : '') + renderSections(block.sections); } /* Add-DhRecordList block: one row per record (status dot, title, sub line, up to four metrics, chevron) that expands to its sections. The bodies are built once, hidden; print shows them all through CSS, with no handler needed. */ function renderRecordList(container, block) { container.className = 'recordlist-section'; var items = block.items || []; var uid = 'rec-' + block.id; var html = items.map(function (r, i) { var open = !!block.openAll; var rid = uid + '-' + i; var metrics = (r.metrics || []).map(function (m) { return '<span class="record-metric' + secCls(m.status) + '"><span class="record-metric-value">' + esc(m.value) + '</span><span class="record-metric-label">' + esc(m.label) + '</span></span>'; }).join(''); return '<div class="record' + (open ? ' open' : '') + '" data-ri="' + i + '" data-record-id="' + esc(r.id) + '">' + '<button type="button" class="record-head" aria-expanded="' + (open ? 'true' : 'false') + '" aria-controls="' + esc(rid) + '-body">' + '<span class="record-dot' + secCls(r.status) + '" aria-hidden="true"></span>' + '<span class="record-text"><span class="record-title">' + esc(r.title) + '</span>' + (r.subtitle ? '<span class="record-sub">' + esc(r.subtitle) + '</span>' : '') + '</span>' + '<span class="record-metrics">' + metrics + '</span>' + '<span class="record-chevron" aria-hidden="true">' + (open ? '▾' : '▸') + '</span>' + '</button>' + '<div class="record-body" id="' + esc(rid) + '-body"' + (open ? '' : ' hidden') + '>' + (renderSections(r.sections) || '<p class="dh-text">No further detail.</p>') + '</div>' + '</div>'; }).join(''); var tools = ''; if (items.length > 1) { tools = '<div class="recordlist-toolbar">' + (block.filterable !== false ? '<input type="search" class="recordlist-filter" placeholder="Filter…" aria-label="Filter records">' : '') + '<button type="button" class="recordlist-btn" data-act="open">Expand all</button>' + '<button type="button" class="recordlist-btn" data-act="close">Collapse all</button></div>'; } container.innerHTML = (block.title ? '<h3 class="recordlist-title">' + esc(block.title) + '</h3>' : '') + tools + (items.length ? '<div class="recordlist">' + html + '</div><p class="recordlist-none" hidden>No records match the filter.</p>' : '<p class="recordlist-none">No records.</p>'); function setOpen(el, open) { el.classList.toggle('open', open); var head = el.querySelector('.record-head'), body = el.querySelector('.record-body'); if (head) head.setAttribute('aria-expanded', open ? 'true' : 'false'); if (body) { if (open) body.removeAttribute('hidden'); else body.setAttribute('hidden', ''); } var chev = el.querySelector('.record-chevron'); if (chev) chev.innerHTML = open ? '▾' : '▸'; } container.querySelectorAll('.record-head').forEach(function (head) { head.addEventListener('click', function () { var el = head.parentNode; setOpen(el, !el.classList.contains('open')); }); }); container.querySelectorAll('.recordlist-btn').forEach(function (b) { b.addEventListener('click', function () { var open = b.getAttribute('data-act') === 'open'; container.querySelectorAll('.record').forEach(function (el) { if (!el.hidden) setOpen(el, open); }); }); }); var hay = items.map(function (r) { var parts = [r.title, r.subtitle, r.status]; (r.metrics || []).forEach(function (m) { parts.push(m.label, m.value); }); (r.sections || []).forEach(function (s) { parts.push(s.title, s.text); (s.items || []).forEach(function (it) { parts.push(it.label, it.value, it.text, it.note); }); (s.rows || []).forEach(function (row) { (row || []).forEach(function (c) { parts.push(c && typeof c === 'object' ? c.text : c); }); }); }); return parts.filter(function (p) { return p !== undefined && p !== null; }).join('\n').toLowerCase(); }); var input = container.querySelector('.recordlist-filter'); if (input) input.addEventListener('input', function () { var term = input.value.trim().toLowerCase(), visible = 0; container.querySelectorAll('.record').forEach(function (el) { var ok = !term || hay[parseInt(el.getAttribute('data-ri'), 10)].indexOf(term) !== -1; el.hidden = !ok; if (ok) visible++; }); var none = container.querySelector('.recordlist-none'); if (none) none.hidden = visible > 0; }); } /* ── The drawer ─────────────────────────────────────────────────────── */ var _drawer = null; /* { root, backdrop, body, title, sub, close, opener, restore } */ function dhDrawerEnsure() { if (_drawer) return _drawer; var backdrop = document.createElement('div'); backdrop.className = 'dh-drawer-backdrop'; backdrop.hidden = true; var root = document.createElement('aside'); root.className = 'dh-drawer'; root.setAttribute('role', 'dialog'); root.setAttribute('aria-modal', 'true'); root.setAttribute('aria-label', 'Details'); root.hidden = true; root.innerHTML = '<header class="dh-drawer-head">' + '<div class="dh-drawer-heading"><div class="dh-drawer-title"></div><div class="dh-drawer-sub"></div></div>' + '<button type="button" class="dh-drawer-close" aria-label="Close details">×</button>' + '</header>' + '<div class="dh-drawer-body"></div>'; document.body.appendChild(backdrop); document.body.appendChild(root); _drawer = { root: root, backdrop: backdrop, body: root.querySelector('.dh-drawer-body'), title: root.querySelector('.dh-drawer-title'), sub: root.querySelector('.dh-drawer-sub'), close: root.querySelector('.dh-drawer-close'), opener: null, restore: null }; _drawer.close.addEventListener('click', dhDrawerClose); backdrop.addEventListener('click', dhDrawerClose); root.addEventListener('keydown', function (e) { if (e.key !== 'Tab') return; var f = root.querySelectorAll('button, a[href], input, [tabindex="0"]'); if (!f.length) return; var first = f[0], last = f[f.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }); document.addEventListener('keydown', function (e) { if (e.key === 'Escape' && _drawer && !_drawer.root.hidden) { e.stopPropagation(); dhDrawerClose(); } }, true); return _drawer; } /* spec: { title, subtitle, sections:[...], tableId, filter } opts: { opener: element to give focus back to, restore: selector used when the opener was re-rendered } When spec.tableId is set a "Show in table" button is added. */ function dhDrawerOpen(spec, opts) { var d = dhDrawerEnsure(); spec = spec || {}; opts = opts || {}; d.opener = opts.opener || document.activeElement || null; d.restore = opts.restore || null; d.title.textContent = spec.title == null ? '' : String(spec.title); d.sub.textContent = spec.subtitle == null ? '' : String(spec.subtitle); d.sub.hidden = !d.sub.textContent; d.root.setAttribute('aria-label', d.title.textContent || 'Details'); var html = renderSections(spec.sections); if (spec.tableId) html += '<div class="dh-drawer-actions"><button type="button" class="dh-drawer-jump">Show in table →</button></div>'; d.body.innerHTML = html || '<p class="dh-text">No further detail.</p>'; var jump = d.body.querySelector('.dh-drawer-jump'); if (jump) jump.addEventListener('click', function () { dhDrawerClose(true); jumpToTable(spec.tableId, spec.filter); }); d.backdrop.hidden = false; d.root.hidden = false; d.root.classList.add('open'); d.body.scrollTop = 0; d.close.focus(); } /* skipFocus: the caller is about to move focus elsewhere itself. */ function dhDrawerClose(skipFocus) { if (!_drawer || _drawer.root.hidden) return; var d = _drawer; d.root.classList.remove('open'); d.root.hidden = true; d.backdrop.hidden = true; if (skipFocus === true) return; var target = d.opener; if ((!target || !document.body.contains(target)) && d.restore) target = document.querySelector(d.restore); if (target && target.focus) target.focus(); } window._dhOpenDrawer = dhDrawerOpen; window._dhCloseDrawer = dhDrawerClose; '@ } |