Sideways-scrolling tables only showed a few of the 11 columns at a time on a phone or iPad. Below 1100px (where the nav already collapses) each row now renders as a card: the device as the title and every other column as a labelled line, one card per row on phones and two or more on tablets. - web/mobile.js copies column names onto cells (data-label) for the cards and re-applies them as the dashboard re-renders; no-op on wide screens - Column filters fold behind a "Show filters" button with an active count - Custom View keeps its sort/filter header as a strip of chips - Terminal: Follow and the "your actions (simulated)" legend move into the terminal window's title bar; tabs read "Server" / "Worker" on phones, so LIVE, the legend and Follow are all visible on small screens
94 lines
4.1 KiB
JavaScript
94 lines
4.1 KiB
JavaScript
/*
|
|
* Card layout support for small screens.
|
|
*
|
|
* On phones and tablets (see mobile.css) each table row is shown as a card, with every
|
|
* cell rendered as "Column name: value". CSS can't look up a cell's column header, so
|
|
* this copies each header's text onto its cells as data-label. The dashboard re-renders
|
|
* its tables constantly, so labels are reapplied (once per frame at most) whenever the
|
|
* DOM changes. On wide screens it does nothing.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
const cardsQuery = window.matchMedia('(max-width: 1100px)');
|
|
const clean = text => text.replace(/[\u2195\u25B2\u25BC\u00D7\u2715]/g, '').replace(/\s+/g, ' ').trim();
|
|
|
|
function labelTable(table) {
|
|
const thead = table.tHead;
|
|
if (!thead) return;
|
|
|
|
// The header row is the one with the most text (Custom View has a drag-handle row above it)
|
|
let headerRow = null, best = -1;
|
|
for (const row of thead.rows) {
|
|
if (row.classList.contains('filter-row')) continue;
|
|
const named = Array.from(row.cells).filter(c => clean(c.textContent)).length;
|
|
if (named > best) { best = named; headerRow = row; }
|
|
}
|
|
if (!headerRow) return;
|
|
|
|
for (const row of thead.rows) {
|
|
if (row === headerRow) row.classList.add('card-label-row');
|
|
else if (!row.classList.contains('filter-row')) row.classList.add('card-aux-row');
|
|
}
|
|
|
|
const labels = Array.from(headerRow.cells).map(c => clean(c.textContent));
|
|
const apply = row => {
|
|
Array.from(row.cells).forEach((cell, i) => {
|
|
const label = cell.colSpan > 1 ? '' : (labels[i] || '');
|
|
if (cell.dataset.label !== label) cell.dataset.label = label;
|
|
});
|
|
};
|
|
thead.querySelectorAll('tr.filter-row').forEach(apply);
|
|
for (const body of table.tBodies) for (const row of body.rows) apply(row);
|
|
|
|
addFilterToggle(table, thead.querySelector('tr.filter-row'));
|
|
}
|
|
|
|
// Column filters take a lot of room above every card group, so they fold behind a
|
|
// button that also shows how many filters are active.
|
|
function addFilterToggle(table, filterRow) {
|
|
if (!filterRow) return;
|
|
let btn = table.previousElementSibling;
|
|
if (!btn || !btn.classList.contains('card-filter-toggle')) {
|
|
btn = document.createElement('button');
|
|
btn.type = 'button';
|
|
btn.className = 'card-filter-toggle';
|
|
btn.addEventListener('click', () => { table.classList.toggle('show-filters'); updateToggle(table, btn); });
|
|
table.parentNode.insertBefore(btn, table);
|
|
filterRow.addEventListener('input', () => updateToggle(table, btn));
|
|
filterRow.addEventListener('change', () => updateToggle(table, btn));
|
|
}
|
|
updateToggle(table, btn);
|
|
}
|
|
function updateToggle(table, btn) {
|
|
const row = table.tHead && table.tHead.querySelector('tr.filter-row');
|
|
if (!row) return;
|
|
const active = Array.from(row.querySelectorAll('input[type="text"]')).filter(i => i.value.trim()).length
|
|
+ row.querySelectorAll('input[type="checkbox"]:checked').length;
|
|
const open = table.classList.contains('show-filters');
|
|
const html = `${open ? 'Hide' : 'Show'} filters${active ? ` <span class="count">${active}</span>` : ''}`;
|
|
if (btn.innerHTML !== html) btn.innerHTML = html;
|
|
}
|
|
|
|
let queued = false;
|
|
function run() {
|
|
queued = false;
|
|
if (!cardsQuery.matches) return;
|
|
document.querySelectorAll('table.master-table').forEach(labelTable);
|
|
}
|
|
function schedule() {
|
|
if (queued) return;
|
|
queued = true;
|
|
requestAnimationFrame(run);
|
|
}
|
|
|
|
function start() {
|
|
new MutationObserver(schedule).observe(document.body, { childList: true, subtree: true });
|
|
if (cardsQuery.addEventListener) cardsQuery.addEventListener('change', schedule);
|
|
else cardsQuery.addListener(schedule);
|
|
schedule();
|
|
}
|
|
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start);
|
|
else start();
|
|
})();
|