mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-10-30 06:35:13 +08:00
This commit is contained in:
1 parent
8abb1eac74
commit
8d995489a2
74 files changed
+3288
-2289
No files matched your search
@@ -252,6 +252,29 @@ function fitChrome() {
|
||||
* did NOT fit, .fs-bar-stack has already given it a row of its own. */
|
||||
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
|
||||
fitCluster(bar, menu);
|
||||
|
||||
publishBarHeight(bar);
|
||||
}
|
||||
|
||||
/* ---- HOW TALL THE BAR ACTUALLY IS, for whoever has to stick underneath it ----
|
||||
*
|
||||
* A data table's header row sticks while its rows scroll past (theme/30-tables.css). Where the
|
||||
* DOCUMENT scrolls — the top layout, and the sidebar layout once it has become a bar — the sticky
|
||||
* bar is what the header has to clear, and `--fs-bar-h` is only its DESIGNED height: the bar grows
|
||||
* when the brand wraps, when the menu takes a row of its own (.fs-bar-stack) or when the cluster
|
||||
* does (.fs-bar-actrow), and every one of those is decided a few lines above by measurement.
|
||||
* So the measurement is published, and the CSS falls back to the token when it is missing.
|
||||
*
|
||||
* Written only on a CHANGE and rounded to the pixel: this runs on every fit pass, and a custom
|
||||
* property write on :root invalidates style for the whole document. */
|
||||
let _barH = 0;
|
||||
function publishBarHeight(bar) {
|
||||
const root = document.documentElement;
|
||||
if (!bar) return;
|
||||
const h = Math.round(bar.getBoundingClientRect().height);
|
||||
if (!(h > 0) || h === _barH) return;
|
||||
_barH = h;
|
||||
root.style.setProperty('--fs-bar-live', `${h}px`);
|
||||
}
|
||||
|
||||
/* ---- does the right-hand cluster still fit beside the brand? ----
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use strict';
|
||||
'require baseclass';
|
||||
'require ui';
|
||||
|
||||
/* fs-fit — the theme's ONE "does it still fit?" engine; add fit logic here, do not grow a second
|
||||
* observer. No CSS query can ask what the CONTENT needs (media = viewport, container =
|
||||
@@ -21,21 +22,7 @@
|
||||
|
||||
const _fitters = [];
|
||||
let _rafPending = false;
|
||||
let _ro = null, _mo = null;
|
||||
/* one canvas for the whole document: wordFloor() measures text with it, and creating one per call
|
||||
* is the expensive half of that measurement. `null` = not asked yet, `false` = asked and refused —
|
||||
* the same three-state slot fs-widgets.js's rasterCtx() keeps, and for the same reason: a browser
|
||||
* with canvas turned off (an anti-fingerprinting extension, a WebView, memory pressure) answers
|
||||
* null, and retrying that on every fit pass is a cost with no chance of a different answer. */
|
||||
let _cx = null;
|
||||
function textCtx() {
|
||||
if (_cx === null) {
|
||||
try { _cx = document.createElement('canvas').getContext('2d') || false; }
|
||||
catch (e) { _cx = false; }
|
||||
}
|
||||
return _cx;
|
||||
}
|
||||
const SPACE_RE = /\s+/;
|
||||
let _ro = null, _mo = null, _moFlag = null;
|
||||
|
||||
/* Run every fitter NOW, synchronously. A fitter must be idempotent — this fires on every
|
||||
* relevant mutation. */
|
||||
@@ -69,12 +56,42 @@ function watch(el) {
|
||||
/* Rule 2's mutation side. Deliberately NOT filtered by node type: a filter is a second place to
|
||||
* get wrong (the table fitter's own once said `table.table`, and LuCI renders most of its tables
|
||||
* as DIVs — so the poll never re-measured at all), and run() is a handful of measurements. */
|
||||
/* THE CONTENT IS IN TWO PLACES, and watching one of them was a bug with a phone screenshot behind it.
|
||||
*
|
||||
* `ui.showModal` builds its dialog inside `#modal_overlay`, which ui's `__init__` appends to `<body>`
|
||||
* beside #view — so a dialog's content mutates NOTHING inside the observed host. A table opened in the
|
||||
* wireless scan dialog was therefore measured only if some unrelated mutation in #view happened to
|
||||
* run the fitters, and its rows (which that dialog re-renders once a second) were never re-measured
|
||||
* at all. Both roots get the same observer and the same ResizeObserver.
|
||||
*
|
||||
* `require ui` above is what makes the overlay exist by the time this runs: it is created in ui's
|
||||
* constructor, and luci-base instantiates a class exactly once, at the first require. */
|
||||
function observeContent() {
|
||||
if (_mo) return;
|
||||
const host = document.getElementById('view') || document.body;
|
||||
_mo = new MutationObserver(run);
|
||||
_mo.observe(host, { childList: true, subtree: true });
|
||||
watch(host);
|
||||
for (const host of [ document.getElementById('view') || document.body, document.getElementById('modal_overlay') ]) {
|
||||
if (!host) continue;
|
||||
_mo.observe(host, { childList: true, subtree: true });
|
||||
watch(host);
|
||||
}
|
||||
/* AND THE MOMENT THE DIALOG BECOMES VISIBLE, which no mutation inside it announces: `showModal`
|
||||
* writes the content FIRST and adds `modal-overlay-active` to <body> after, so the pass that the
|
||||
* content mutation triggers still sees a closed dialog (fs-select.js skips one — a hidden overlay
|
||||
* shrink-fits, so it would be measuring a width the dialog will never have). Nothing else changes
|
||||
* afterwards, so without this the dialog's table would wait for its first poll to be fitted. One
|
||||
* attribute, on one element.
|
||||
*
|
||||
* A SECOND OBSERVER, and it has to be one — this is a spec fact, not a preference.
|
||||
* `MutationObserver.observe()` REPLACES the options of an existing registration for the same node,
|
||||
* so calling it here on `document.body` would have silently dropped the `{childList, subtree}`
|
||||
* registration above on any page where `#view` does not exist and `body` IS the content host: the
|
||||
* fitters would then never run on a content mutation again, and nothing would say so.
|
||||
*
|
||||
* Merging the two into one call is the other wrong answer: `subtree: true` plus an attribute
|
||||
* filter wakes `run()` on every class change anywhere in the document, and the poll rewrites
|
||||
* classes on rows once a second. Two observers, one narrow node each, same callback. */
|
||||
_moFlag = new MutationObserver(run);
|
||||
_moFlag.observe(document.body, { attributes: true, attributeFilter: [ 'class' ] });
|
||||
}
|
||||
|
||||
return baseclass.extend({
|
||||
@@ -128,114 +145,18 @@ return baseclass.extend({
|
||||
return p.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
|
||||
},
|
||||
|
||||
/* Does `el` need more width than it has been given? */
|
||||
/* Does `el` need more width than it has been given?
|
||||
*
|
||||
* THE ONE QUESTION LEFT, and it is the browser's own answer. `wordFloor()` and `textLines()`
|
||||
* used to stand beside it — a canvas reimplementation of min-content and a line counter — and
|
||||
* both existed only because the stylesheet lowered every cell's min-content to one character,
|
||||
* so a starved column produced no overflow for this to read. theme/30-tables.css gives a data
|
||||
* table an honest floor for as long as it is a table, so the starvation cannot happen and the
|
||||
* reconstructions are gone: 75 lines of approximation (whitespace-split words, one font per
|
||||
* column, `iiii` ranked above `WWW`) that cost about 1 ms per pass on a 114-row table and, on
|
||||
* `WPA2-PSK/CCMP`, claimed 144px where the engine's own floor is 93. */
|
||||
overflows(el) {
|
||||
return el.scrollWidth > this.roomFor(el) + 1; /* +1: sub-pixel rounding */
|
||||
},
|
||||
|
||||
/* THE NARROWEST THIS TABLE CAN BE WITHOUT BREAKING A WORD THROUGH — its own breakpoint, in px.
|
||||
*
|
||||
* Every other "does it fit" question here is asked of the browser. This one cannot be: it is
|
||||
* exactly the table's min-content width, and `overflow-wrap: anywhere` (base/40-tables.css) makes
|
||||
* the browser answer ONE CHARACTER per column. That is deliberate — an unbreakable ICCID must not
|
||||
* push a table nobody measures out of its card — but it means a measured table can be starved to
|
||||
* a ribbon of fragments and still report, truthfully, that it fits. Asking the engine the honest
|
||||
* question by flipping `overflow-wrap` for one layout does not work either: Blink returns the same
|
||||
* min-content for `normal`, `break-word` and `anywhere` on a table (measured — 645px in all three,
|
||||
* with the widest word alone needing 367), so the number simply is not available from layout.
|
||||
*
|
||||
* So compute it: per COLUMN, the width of the widest WORD any of its cells has to show, plus that
|
||||
* cell's own side padding; summed across columns. A `nowrap`/`pre` column takes its whole text
|
||||
* instead, because that is what it has committed to showing. Nothing here is a threshold — the
|
||||
* result is the table's content measured in the table's own fonts, so every table gets its own
|
||||
* number and none was picked by anyone.
|
||||
*
|
||||
* Two approximations, both stated rather than hidden:
|
||||
* - the FONT is sampled once per column, from the first data row. A column that changes face
|
||||
* row by row would be measured in the first row's; no LuCI table does that, and the
|
||||
* alternative is a `getComputedStyle` per cell, which is the expensive call here.
|
||||
* - the widest word is picked by CHARACTER COUNT and only that one is measured, so a column of
|
||||
* one font is one `measureText` per new maximum instead of one per cell. Within a single font
|
||||
* that ranks `iiii` above `WWW`; it costs a few px on the floor and takes the walk over
|
||||
* Processes (114 rows) from 6ms to about 1ms.
|
||||
*
|
||||
* A `colSpan` cell contributes its whole floor to one column, which over-states that column and
|
||||
* under-states its neighbours by the same amount — the sum, which is what the caller compares, is
|
||||
* unaffected. */
|
||||
wordFloor(t) {
|
||||
const rows = t.querySelectorAll('.tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder)');
|
||||
if (!rows.length) return 0;
|
||||
/* No 2D context, no floor to report — and 0 is the honest answer, not a failure: it says
|
||||
* "this test has nothing to add", leaving the other two stack tests (does it overflow, is a
|
||||
* one-token column shredded) to decide. Throwing here would cost far more than the test is
|
||||
* worth: fs-select's fitTables() strips `fs-stacked` BEFORE it measures (rule 1), and this is
|
||||
* the last term of that `||`, so the throw would escape the row walk with the class already
|
||||
* gone — every stackable table left un-stacked, every later table in the pass unfitted, once
|
||||
* a second on a polled page. */
|
||||
const cx = textCtx();
|
||||
if (!cx) return 0;
|
||||
const floors = [], longest = [];
|
||||
let cols = null;
|
||||
for (const row of rows) {
|
||||
const cells = row.children;
|
||||
if (!cols) {
|
||||
cols = [];
|
||||
for (const c of cells) {
|
||||
const s = getComputedStyle(c);
|
||||
cols.push({
|
||||
font: `${s.fontStyle} ${s.fontWeight} ${s.fontSize} ${s.fontFamily}`,
|
||||
tracking: parseFloat(s.letterSpacing) || 0,
|
||||
pad: parseFloat(s.paddingLeft) + parseFloat(s.paddingRight),
|
||||
whole: (s.whiteSpace === 'nowrap' || s.whiteSpace === 'pre')
|
||||
});
|
||||
}
|
||||
}
|
||||
for (let i = 0; i < cells.length; i++) {
|
||||
const text = (cells[i].textContent || '').trim();
|
||||
if (!text) continue;
|
||||
const col = cols[i] || cols[cols.length - 1];
|
||||
let word = '';
|
||||
if (col.whole) word = text;
|
||||
else for (const w of text.split(SPACE_RE)) if (w.length > word.length) word = w;
|
||||
if (!(word.length > (longest[i] || 0))) continue;
|
||||
longest[i] = word.length;
|
||||
cx.font = col.font;
|
||||
const need = cx.measureText(word).width + (col.tracking * word.length) + col.pad;
|
||||
if (!(floors[i] >= need)) floors[i] = need;
|
||||
}
|
||||
}
|
||||
let sum = 0;
|
||||
for (const f of floors) sum += (f || 0);
|
||||
return sum;
|
||||
},
|
||||
|
||||
/* How many LINE BOXES of text does `el` render? The fact behind "this column has been
|
||||
* squeezed into a tower" — a cell that has to break its own words is a column that has run
|
||||
* out of width, which is a thing no viewport query can ask.
|
||||
*
|
||||
* NOT height / line-height: the assoclist's first cell is an `.ifacebadge`, a flex COLUMN
|
||||
* with a 32px icon over the text, so a third of that height is not text at all. Ranges over
|
||||
* the TEXT NODES only, so an <img> cannot be counted as a line.
|
||||
*
|
||||
* Cluster by the rect's TOP, not by vertical overlap: consecutive lines OVERLAP. Measured on
|
||||
* the router — tops 15-16px apart while each rect is 17-18px tall (the font's box is taller
|
||||
* than the line advance), so an overlap test merged an 8-line tower into ONE line and the
|
||||
* whole check silently never fired. Half a line of tolerance also merges what belongs on one
|
||||
* line: a `<small>` shares the baseline but sits a few px lower, and is not a new line. */
|
||||
textLines(el) {
|
||||
const walker = document.createTreeWalker(el, NodeFilter.SHOW_TEXT);
|
||||
const range = document.createRange();
|
||||
const rects = [];
|
||||
for (let n; (n = walker.nextNode());) {
|
||||
if (!n.nodeValue.trim()) continue;
|
||||
range.selectNodeContents(n);
|
||||
for (const r of range.getClientRects())
|
||||
if (r.width > 0.5 && r.height > 0.5) rects.push(r);
|
||||
}
|
||||
rects.sort((a, b) => a.top - b.top);
|
||||
let lines = 0, top = -Infinity;
|
||||
for (const r of rects)
|
||||
if (r.top - top > r.height * 0.5) { lines++; top = r.top; }
|
||||
return lines;
|
||||
}
|
||||
|
||||
});
|
||||
@@ -170,7 +170,7 @@ function enhance(sel) {
|
||||
* `:has(.tr.table-titles)` the style engine re-evaluated on every mutation of these polled tables
|
||||
* (Processes/routes/leases). Not a .cbi-section-table — config forms keep their own layout.
|
||||
*
|
||||
* `.table`, not `table.table` — the SAME selector relevant() and STACKABLE use. Stock LuCI
|
||||
* `.table`, not `table.table` — the SAME selector relevant() and stackables() use. Stock LuCI
|
||||
* happens to emit only real <table>s, but a third-party luci-app-* may emit a <div class="table">
|
||||
* (coverage rule, docs/conventions.md), which a tag qualifier would pass over so it could never card.
|
||||
*
|
||||
@@ -198,8 +198,46 @@ function enhance(sel) {
|
||||
* Re-running is what the two functions below are already written for: both are additive, both skip
|
||||
* what is already done, and neither re-decides anything (adoptMarkup() settles the "is this ours to
|
||||
* rewrite?" question once, at claim time, and remembers the answer on the element). */
|
||||
const FOREIGN_TABLE = '#view .table:not(.cbi-section-table), ' +
|
||||
'#view table:not(.table):not(.cbi-section-table)';
|
||||
/* ---- AND `#view` IS NOT THE ONLY PLACE A TABLE LANDS ----
|
||||
*
|
||||
* `ui.showModal` (luci-base ui.js) parks its dialog on `#modal_overlay`, which ui's own `__init__`
|
||||
* appends to `<body>` — a SIBLING of #view, not a descendant. Every selector in this file said
|
||||
* `#view`, so a table in a dialog was reached by none of it: never tagged `.fs-dt`, never
|
||||
* captioned, never measured, never carded. The dialog that shows it is the wireless scan
|
||||
* ("Join Network: Wireless Scan", luci-mod-network/wireless.js — a seven-column table built with
|
||||
* `E('table', {class: 'table'})` and fed to `cbi_update_table`).
|
||||
*
|
||||
* Measured on the stand with that exact markup, at a 390px viewport: in #view the table cards
|
||||
* (`.fs-stacked`, 358px wide, every value on its own labelled line); in the dialog it stayed a
|
||||
* table 373px wide inside 317px of room — 56px past the dialog's edge, with the Encryption column
|
||||
* given 10px and `mixed WPA/WPA2 PSK (CCMP…)` spelled one character per line. Reported from a
|
||||
* phone, and the screenshot is exactly that tower.
|
||||
*
|
||||
* So the roots are listed once, here, and every table query in this file is built from them. The
|
||||
* overlay is a scroll container (base/60-modal.css) and theme/70-modal.css already spends a block
|
||||
* on keeping a wide child from scrolling the dialog off the screen — a table is the widest child
|
||||
* there is, and it belongs to the same rule.
|
||||
*
|
||||
* ---- AND A CLOSED DIALOG IS NOT A CONTENT ROOT, THOUGH IT IS STILL IN THE DOM ----
|
||||
*
|
||||
* `hideModal()` only drops a class off `<body>`; the dialog KEEPS the last markup it was given until
|
||||
* the next `showModal()` overwrites it, and `visibility: hidden` leaves a box behind. Measured after
|
||||
* closing a dialog: the overlay shrink-fits to 270px (its `inset-inline: 0` comes with the open
|
||||
* class), so the table inside reports 236px of room and stacks — a decision taken about a width the
|
||||
* dialog will never have, on rows nobody can see, re-taken on every pass a polled page makes. So the
|
||||
* dialog counts as a root only while it is open, which is the same fact `body.modal-overlay-active`
|
||||
* already carries for the CSS.
|
||||
*
|
||||
* The flag flips AFTER the content is written (`showModal` appends, then adds the class), so a
|
||||
* mutation-driven pass would run one moment too early and never look again — which is why
|
||||
* `fs-fit.js` also watches that class, and why this is a question asked per PASS rather than a
|
||||
* selector fixed at load. */
|
||||
const ROOTS = [ '#view', '#modal_overlay' ];
|
||||
const inRoots = (sel, roots) => roots.map((r) => `${r} ${sel}`).join(', ');
|
||||
const liveRoots = () => (document.body.classList.contains('modal-overlay-active') ? ROOTS : ROOTS.slice(0, 1));
|
||||
|
||||
const foreignTables = () => inRoots('.table:not(.cbi-section-table)', liveRoots()) + ', ' +
|
||||
inRoots('table:not(.table):not(.cbi-section-table)', liveRoots());
|
||||
|
||||
/* The FOURTH header markup, and the one only a foreign table produces: `<table><tr><th>…`, with no
|
||||
* `<thead>` for the parser to imply and none of LuCI's class names. It is the exact shape the phone
|
||||
@@ -217,7 +255,7 @@ function headerRow(t) {
|
||||
}
|
||||
|
||||
function tagDataTables() {
|
||||
document.querySelectorAll(FOREIGN_TABLE).forEach((t) => {
|
||||
document.querySelectorAll(foreignTables()).forEach((t) => {
|
||||
/* FOUR header markups, and each missing one cost a page. L.ui.Table emits
|
||||
* `.tr.table-titles`; the apk Software page emits `.tr.cbi-section-table-titles` (missing
|
||||
* it is why the package list once needed a stacking block of its own); and a third-party
|
||||
@@ -270,7 +308,7 @@ function tagDataTables() {
|
||||
function adoptMarkup(t, head) {
|
||||
/* DECIDED ONCE, AT CLAIM TIME, and only for a table that speaks none of this vocabulary — then
|
||||
* READ on every pass, because the caller now revisits a table it has already claimed (see
|
||||
* FOREIGN_TABLE). Asking the question afresh each pass instead would answer "already adopted"
|
||||
* foreignTables()). Asking the question afresh each pass instead would answer "already adopted"
|
||||
* the moment we adopted it, and the fresh rows a poll brings in bare would never be taken.
|
||||
* Asking it at all is what keeps the theme's hands off LuCI's own markup: the apk Software list
|
||||
* heads its table with `.tr.cbi-section-table-titles`, and blindly adding `table-titles` to that
|
||||
@@ -382,99 +420,243 @@ function labelCells(t, head) {
|
||||
* table then reported needing 1747px where it really needs 1190px and overflowed its section by
|
||||
* 557px — an overflow the CSS-only version never had. A data table has no widgets, which is why
|
||||
* it is the one that gets measured. */
|
||||
const STACKABLE = '#view .table.fs-dt';
|
||||
const stackables = () => inRoots('.table.fs-dt', liveRoots());
|
||||
|
||||
/* "Too cramped to be a table any more" — a DESIGN judgement. It has to be one: these tables do
|
||||
* NOT overflow when the room runs out (their cells break anywhere), they compress into an
|
||||
* unreadable ribbon. Do NOT give the cells a min-width so that "cramped" MANUFACTURES an
|
||||
* overflow: tried, and it carded the firewall's zone table at 1420px and still overflowed by
|
||||
* 39px once carded — a floor big enough to force the overflow is big enough to break the card. */
|
||||
/* "Too cramped to be a table any more" — a DESIGN judgement, and the only number in this file.
|
||||
*
|
||||
* It survives the honest floor below rather than being replaced by it: a four-column table of short
|
||||
* values (Startup, Connections) still FITS at 380px of room, technically, as four ribbons of two
|
||||
* words each. Nothing measures "unreadable", so somebody has to say it, and stock LuCI says 600px of
|
||||
* viewport. Do NOT give the cells a min-width so that "cramped" MANUFACTURES an overflow: tried, and
|
||||
* it carded the firewall's zone table at 1420px and still overflowed by 39px once carded — a floor
|
||||
* big enough to force the overflow is big enough to break the card. */
|
||||
const CRAMPED = 568; /* stock LuCI cards its tables at a 600px viewport; below the 767px tier
|
||||
* .fs-content pads var(--fs-space-4) a side, 16px at the default density,
|
||||
* so 600 -> 568 of room. A fixed number and not a re-read of that token on
|
||||
* purpose: the threshold is the DESIGN judgement above, and Compact density
|
||||
* shrinking the gutter to 10px is not a reason to keep a table wider. */
|
||||
|
||||
/* The ribbon has one more shape, and CRAMPED cannot see it: the table has room by the number
|
||||
* above and still shreds its FIRST column, because auto table layout hands width out by what
|
||||
* each column DEMANDS. The leftmost column is the row's identity and usually the least greedy —
|
||||
* a wide neighbour (a hostname plus an IPv6, a modulation string) simply takes the width, and
|
||||
* `overflow-wrap: anywhere` (theme/30-tables.css) lets the identity be squeezed with no floor:
|
||||
* it breaks mid-word rather than overflow, so there is no overflow for fit.overflows() to read.
|
||||
/* ---- THE REMEDY LADDER: cheapest first, re-measured at every rung ----
|
||||
*
|
||||
* Measured on the router (Wireless, one station, `Access Point "vaka_devices" (phy6-ap0)`):
|
||||
* viewport 900 -> the column is 101px and 5 lines, 850 -> 80px and 7, 800 -> 76px and 8, and at
|
||||
* NO width did the table card — a nine-line tower of half-words next to columns with room to
|
||||
* spare (issue #7). Below 767 the MAC column drops out (the stock phone contract) and the
|
||||
* column springs back to 167px, which is why this only ever bit between roughly 780 and 900.
|
||||
* There used to be four terms in one `||`: `room < CRAMPED`, `overflows()`, `idTower()` (is the first
|
||||
* column a tower of half-words?) and `wordFloor() > room` (does the table need more width than its
|
||||
* own content can be squeezed into?). Three of the four existed because the CSS had told the engine
|
||||
* that any value may break anywhere, which lowers a column's min-content to ONE CHARACTER: with no
|
||||
* floor, a column could be starved instead of the table overflowing, and `overflows()` — the one
|
||||
* question the browser answers exactly — went blind. `wordFloor()` reconstructed the floor with a
|
||||
* canvas and a whitespace split, which is UAX #14 done by hand and wrong in both directions
|
||||
* (`WPA2-PSK/CCMP` measured 144px against a real 93px, so tables carded that had room).
|
||||
*
|
||||
* So: past this many lines the identity has stopped being readable and the card view — which
|
||||
* gives every field its own labelled row — is simply better. A DESIGN judgement like CRAMPED,
|
||||
* and it has to be one: any number of lines is legible in isolation. 5 is what the reporter
|
||||
* asked for and what the measurements above bracket. Only the first column, and deliberately:
|
||||
* a value column wrapping to a few lines is a value being shown, not a table falling apart. */
|
||||
const MAX_ID_LINES = 5;
|
||||
* theme/30-tables.css now gives a data table `overflow-wrap: break-word` for as long as it is a
|
||||
* table, so the floor is real again and the overflow is honest. That leaves ONE question — does it
|
||||
* fit? — and a ladder of answers when it does not, each one re-measured:
|
||||
*
|
||||
* 1. it fits -> a table, and nothing was written
|
||||
* 2. drop the columns the VIEW marked droppable (`hide-xs`/`hide-sm`) and ask again
|
||||
* 3. let the widest breakable column shred, and ask again
|
||||
* 4. card it
|
||||
*
|
||||
* Rungs 2 and 3 need no threshold, and that is the point: the guard is the second measurement. On a
|
||||
* wide desktop where one base64 key or one process command line is the entire problem, breaking that
|
||||
* column makes the table fit and it stays a table; where every column is over its share, breaking one
|
||||
* changes nothing and the card is right. Nobody picks a fraction.
|
||||
*
|
||||
* Rung 2 is upstream's own priority hint, honoured by measurement instead of by viewport: wireless.js,
|
||||
* connections.js and channel_analysis.js mark their least valuable columns `hide-xs`, `ui.Table`
|
||||
* copies the class from the header cell onto every body cell (ui.js), and until now the theme only
|
||||
* obeyed it below 767px or in a card. */
|
||||
|
||||
/* Is any row's leftmost cell a tower? Text lines, not height — fs-fit.textLines() explains why.
|
||||
* The height gate in front of it is not premature: this runs on every poll tick (once a second,
|
||||
* every mutation), and Processes/Connections render hundreds of rows whose first cell is a PID
|
||||
* that cannot be a tower — one cheap read each keeps the Range walk for the cells that could. */
|
||||
function idTower(t) {
|
||||
const cells = t.querySelectorAll('.tr > .td:first-child');
|
||||
if (!cells.length) return false;
|
||||
const cs = getComputedStyle(cells[0]);
|
||||
const lh = parseFloat(cs.lineHeight) || parseFloat(cs.fontSize) * 1.2 || 16;
|
||||
for (const cell of cells) {
|
||||
if (cell.clientHeight < MAX_ID_LINES * lh) continue;
|
||||
if (fit.textLines(cell) > MAX_ID_LINES) return true;
|
||||
/* Which cells of a table carry the "may shred" mark, as a column index (-1 = none). Kept on the
|
||||
* element so the common case — the same column as last pass — writes nothing at all. */
|
||||
function markBreakColumn(t, rows, col) {
|
||||
if (t._fsBreakCol === col) return;
|
||||
t._fsBreakCol = col;
|
||||
for (const row of rows) {
|
||||
const cells = row.children;
|
||||
for (let i = 0; i < cells.length; i++)
|
||||
cells[i].classList.toggle('fs-td-break', i === col);
|
||||
}
|
||||
}
|
||||
|
||||
/* Rung 3. The widest column that CAN be shredded, which is never the first (it is the row's
|
||||
* identity — issue #36 is precisely that column being starved) and never a `nowrap`/`pre` one
|
||||
* (`overflow-wrap` is inert there, so marking it would buy a layout and no width).
|
||||
*
|
||||
* With honest floors nothing is starved, so when the table overflows every column sits at its own
|
||||
* min-content and the widest one IS the column holding the longest unbreakable token. No canvas, no
|
||||
* text measurement — one `getComputedStyle` and one rect per column of one row. */
|
||||
function breakWidestColumn(t) {
|
||||
const rows = t.querySelectorAll('.tr:not(.table-titles):not(.cbi-section-table-titles):not(.placeholder)');
|
||||
if (!rows.length) return false;
|
||||
const cells = rows[0].children;
|
||||
let col = -1, widest = 0;
|
||||
for (let i = 1; i < cells.length; i++) {
|
||||
const ws = getComputedStyle(cells[i]).whiteSpace;
|
||||
if (ws === 'nowrap' || ws === 'pre') continue;
|
||||
const w = cells[i].getBoundingClientRect().width;
|
||||
if (w > widest) { widest = w; col = i; }
|
||||
}
|
||||
if (col < 0) return false;
|
||||
markBreakColumn(t, rows, col);
|
||||
if (!fit.overflows(t)) return true;
|
||||
markBreakColumn(t, rows, -1);
|
||||
return false;
|
||||
}
|
||||
|
||||
/* ---- AND THE SAME RIBBON IN A COLUMN THAT IS NOT THE FIRST ----
|
||||
*
|
||||
* There is no second test for it, and that is the fix rather than an omission.
|
||||
*
|
||||
* `idTower` above is deliberately first-column-only, on the grounds that "a value column wrapping to
|
||||
* a few lines is a value being shown, not a table falling apart". That held right up until the value
|
||||
* was one unbreakable token: `overflow-wrap: anywhere` gave such a cell a min-content of ONE
|
||||
* CHARACTER, so auto table layout was free to starve its column to one character, and `overflows()`
|
||||
* then reported — truthfully, uselessly — that the table fit. Reported from a hardware router at
|
||||
* 700-790px of window: the v4 lease table cards there (its `nowrap` columns give it a floor, so it
|
||||
* really does overflow) while the v6 table beside it shredded the DUID, measured at 5 lines with
|
||||
* 674px of room and 7 at 654px, against 1 line at 1160px.
|
||||
*
|
||||
* A first pass answered it with a line count, which is a number somebody picks. This asks the table
|
||||
* instead: `fit.wordFloor()` returns the narrowest the table can be without breaking a word through —
|
||||
* per column, the widest WORD it must show, in that column's own font, summed. Past that width the
|
||||
* browser has to cut through a value, and the card view is what shows values whole. So every table
|
||||
* carries its own breakpoint, derived from its own content, and no threshold was chosen anywhere.
|
||||
*
|
||||
* On the reporting router, at 1190px of room: leases6 asks for 935, the associated-stations table
|
||||
* for 966, the v4 leases for 645, Processes for 794, Connections for 550 and Startup for 381 — so
|
||||
* the two that were unreadable card at roughly a 1000px window, and the four that were fine keep
|
||||
* being tables until the room they actually need runs out. */
|
||||
|
||||
function fitTables() {
|
||||
document.querySelectorAll(STACKABLE).forEach((t) => {
|
||||
document.querySelectorAll(stackables()).forEach((t) => {
|
||||
const was = t.classList.contains('fs-stacked');
|
||||
const wasDrop = t.classList.contains('fs-drop-xs');
|
||||
|
||||
/* fs-fit rule 1: a stacked table is a pile of flex rows and always "fits", so reading
|
||||
* it as it stands un-stacks it and the next frame stacks it again — oscillation. */
|
||||
t.classList.remove('fs-stacked');
|
||||
* it as it stands un-stacks it and the next frame stacks it again — oscillation. Every
|
||||
* mark the ladder can write comes off here, for the same reason. */
|
||||
t.classList.remove('fs-stacked', 'fs-drop-xs');
|
||||
if (t._fsBreakCol !== undefined && t._fsBreakCol !== -1)
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), -1);
|
||||
|
||||
const room = fit.roomFor(t);
|
||||
if (!(room > 0)) { if (was) t.classList.add('fs-stacked'); return; }
|
||||
if (!(room > 0)) {
|
||||
/* detached, hidden, or a dialog that is closed: keep what it had rather than decide
|
||||
* against a width it does not have */
|
||||
if (was) t.classList.add('fs-stacked');
|
||||
if (wasDrop) t.classList.add('fs-drop-xs');
|
||||
return;
|
||||
}
|
||||
|
||||
/* the one judgement a measurement cannot make, and the only number in this file */
|
||||
let stack = room < CRAMPED;
|
||||
if (!stack && fit.overflows(t)) {
|
||||
/* rung 2 — the view author already said which columns are expendable */
|
||||
if (t.querySelector('.hide-xs, .hide-sm')) t.classList.add('fs-drop-xs');
|
||||
/* rung 3 — one column may shred rather than the whole table becoming a stack of cards */
|
||||
if (fit.overflows(t) && !breakWidestColumn(t)) stack = true;
|
||||
}
|
||||
|
||||
/* the two row walks last, cheapest first: idTower reads one column, wordFloor every cell */
|
||||
const stack = room < CRAMPED || fit.overflows(t) || idTower(t) || fit.wordFloor(t) > room;
|
||||
/* write only on a real change: the poll re-renders these tables once a second, and
|
||||
* toggling the class off and on each tick would invalidate style for every row of
|
||||
* Processes/Leases for nothing */
|
||||
if (stack) t.classList.add('fs-stacked');
|
||||
if (stack) {
|
||||
t.classList.add('fs-stacked');
|
||||
/* a card gives every value the whole row, so neither remedy has anything left to do */
|
||||
t.classList.remove('fs-drop-xs');
|
||||
if (t._fsBreakCol !== undefined && t._fsBreakCol !== -1)
|
||||
markBreakColumn(t, t.querySelectorAll('.tr'), -1);
|
||||
}
|
||||
else if (was) t.classList.remove('fs-stacked');
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- AND THE TABLE THAT CANNOT CARD: it scrolls, and it says so ----
|
||||
*
|
||||
* A table with no header row has no captions to print, so a card would give a column of values with
|
||||
* nothing saying what they are — a log, a statistics matrix, a key/value include. Comparison across
|
||||
* rows IS that shape's purpose, which is the one case WCAG 2.2 names in the exception to SC 1.4.10
|
||||
* ("data tables and grids… it is acceptable to provide two-dimensional scrolling for such parts").
|
||||
* theme/30-tables.css already scrolls a BARE `<table>` of that shape; this reaches the one it cannot
|
||||
* name — an app that renders the same thing as `<div class="table">` — and it reaches it by
|
||||
* measurement rather than by selector, which is what makes the widget refusal below possible.
|
||||
*
|
||||
* A SCROLLING TABLE CANNOT HOLD A POPUP. `overflow-x: auto` computes `overflow-y` to `auto` too
|
||||
* (css-overflow-3 §3.1: there is no `auto`/`visible` pair), so it CLIPS every absolutely positioned
|
||||
* thing inside it, and luci-base's `openDropdown()` additionally sizes an open list against the
|
||||
* nearest scroll parent — which would now be the table.
|
||||
*
|
||||
* SO THE ONES THAT HOLD CONTROLS STACK INSTEAD, and refusing to do anything for them was a real
|
||||
* defect rather than caution: Network → Diagnostics is three controls in one header-less row, and at
|
||||
* 320px of room it needs 338 — measured, the row simply ran past `.fs-main`'s clip and the Ping
|
||||
* button was unreachable. Nothing about that shape wants scrolling either: its rows are a FORM, not
|
||||
* data, so there is no comparison across rows to preserve. Rows and cells become blocks, each
|
||||
* control takes the width it was given, the table cannot overflow anything, and no popup is clipped
|
||||
* because nothing became a scroll container.
|
||||
*
|
||||
* That is also what `theme/90-responsive.css` had been doing for this one page since the phone
|
||||
* sweep, keyed by `body[data-page="admin-network-diagnostics"]` and only below 767px. The shape is
|
||||
* not that page's — any app can put a select and a button in a `<div class="table">` — and the width
|
||||
* that matters is the room, not the viewport, so the page rule is gone and the measurement covers
|
||||
* every one of them. */
|
||||
const scrollables = () => inRoots('.table:not(.fs-dt):not(.cbi-section-table)', liveRoots());
|
||||
const HOLDS_CONTROLS = '.cbi-dropdown, .cbi-dynlist, .cbi-tooltip-container, .cbi-progressbar, select, input, textarea, [data-tooltip]';
|
||||
|
||||
/* ---- MAKING A SCROLL BOX REACHABLE, AND HANDING THE MARKUP BACK EXACTLY AS IT WAS FOUND ----
|
||||
*
|
||||
* Firefox has made scrollers focusable for years and Chrome since 132, but only when they hold no
|
||||
* focusable child — a table row full of buttons disqualifies itself — and WebKit has not shipped it
|
||||
* at all (bug 190870, open since 2018). So say it in the markup: a tab stop (SC 2.1.1), and a name so
|
||||
* what receives focus can be announced (SC 4.1.2).
|
||||
*
|
||||
* A ROLE IS WRITTEN ONLY WHERE THERE IS NONE TO LOSE. `<div class="table">` has no implicit role and
|
||||
* takes `group`. A real `<table>` must keep the role it already has: HTML-AAM maps `<td>` to `cell`
|
||||
* and `<th>` to `columnheader`/`rowheader` ONLY while the table element's role is `table` (or
|
||||
* `grid`/`treegrid`), so overwriting it with `group` drops every cell to generic — a screen reader
|
||||
* would then read a flat run of text with no rows and no columns, which is the very structure the
|
||||
* SC 1.4.10 exception exists to preserve. `aria-label` works on either, so the name and the tab stop
|
||||
* are unaffected. Reported in review.
|
||||
*
|
||||
* WHAT IS REMOVED IS WHAT WAS WRITTEN, remembered per element rather than inferred from the value:
|
||||
* an app that had its own `tabindex="0"` or `role="group"` on that table got them taken away when the
|
||||
* table later fitted, because "is it 0 / is it group" cannot tell whose it is. */
|
||||
function reach(t) {
|
||||
if (!t.hasAttribute('tabindex')) { t.tabIndex = 0; t._fsTab = true; }
|
||||
if (!t.hasAttribute('role') && !(t instanceof HTMLTableElement)) {
|
||||
t.setAttribute('role', 'group');
|
||||
t._fsRole = true;
|
||||
}
|
||||
if (!t.hasAttribute('aria-label') && !t.hasAttribute('aria-labelledby')) {
|
||||
const head = t.closest('.cbi-section, fieldset, #view')?.querySelector('h2, h3, h4, legend');
|
||||
t.setAttribute('aria-label', (head && head.textContent.trim()) || _('Table'));
|
||||
t._fsNamed = true;
|
||||
}
|
||||
}
|
||||
|
||||
function unreach(t) {
|
||||
if (t._fsTab) { t.removeAttribute('tabindex'); delete t._fsTab; }
|
||||
if (t._fsRole) { t.removeAttribute('role'); delete t._fsRole; }
|
||||
if (t._fsNamed) { t.removeAttribute('aria-label'); delete t._fsNamed; }
|
||||
}
|
||||
|
||||
function fitScrollables() {
|
||||
document.querySelectorAll(scrollables()).forEach((t) => {
|
||||
const was = t.classList.contains('fs-xscroll');
|
||||
const wasStack = t.classList.contains('fs-rowstack');
|
||||
/* rule 1 again: a scrolling box always "fits" — its overflow is inside it — and so does a
|
||||
* stacked one, whose rows are blocks */
|
||||
t.classList.remove('fs-xscroll', 'fs-rowstack');
|
||||
if (!(fit.roomFor(t) > 0)) {
|
||||
if (was) t.classList.add('fs-xscroll');
|
||||
if (wasStack) t.classList.add('fs-rowstack');
|
||||
return;
|
||||
}
|
||||
const over = fit.overflows(t);
|
||||
const controls = over && !!t.querySelector(HOLDS_CONTROLS);
|
||||
/* a table of CONTROLS stacks; a table of VALUES scrolls and keeps its shape */
|
||||
if (controls) {
|
||||
t.classList.add('fs-rowstack');
|
||||
if (was) unreach(t);
|
||||
return;
|
||||
}
|
||||
const scroll = over;
|
||||
if (scroll === was) { if (was) t.classList.add('fs-xscroll'); return; }
|
||||
if (scroll) {
|
||||
t.classList.add('fs-xscroll');
|
||||
/* A SCROLL BOX THE KEYBOARD CANNOT REACH IS CONTENT THE KEYBOARD CANNOT READ.
|
||||
*
|
||||
* Firefox has made scrollers focusable for years and Chrome since 132, but only when they
|
||||
* hold no focusable child — a table row full of buttons disqualifies itself — and WebKit
|
||||
* has not shipped it at all (bug 190870, open since 2018). So say it in the markup: a
|
||||
* tab stop (SC 2.1.1), a role and a name so what receives focus can be announced
|
||||
* (SC 4.1.2). `group`, not `region`: a region is a landmark, and a status page with four
|
||||
* scrolling tables would put four of them in the landmark list.
|
||||
*
|
||||
* The name comes from the heading the table sits under, which is what a sighted user
|
||||
* reads it as; only the fallback is ours to translate. Never overwritten: an app that
|
||||
* labelled its own table knows better. */
|
||||
reach(t);
|
||||
}
|
||||
else if (was) unreach(t);
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- A PINNED ACTIONS COLUMN IS ONLY VALID FOR THE LAYOUT MODE IT WAS MEASURED IN ----
|
||||
*
|
||||
* luci-base's `form.js` (stabilizeActionColumnWidth) measures the widest
|
||||
@@ -511,7 +693,7 @@ function fitTables() {
|
||||
* into — so wiping the pin cannot change the key and set this oscillating. It fires once per CHANGE,
|
||||
* never per tick, so it does not fight upstream for the pin on a polled page. */
|
||||
function unpinActionColumn() {
|
||||
for (const t of document.querySelectorAll('#view .table.cbi-section-table')) {
|
||||
for (const t of document.querySelectorAll(inRoots('.table.cbi-section-table', liveRoots()))) {
|
||||
if (!t.querySelector('.cbi-section-actions')) continue;
|
||||
/* ---- and CLAIM upstream's resize hook, because under SPA navigation it is a leak ----
|
||||
*
|
||||
@@ -678,7 +860,7 @@ return baseclass.extend({
|
||||
/* A table must be TAGGED .fs-dt before it can be fitted, and re-tagged whenever the poll
|
||||
* brings a fresh one back — so the two travel as one fitter, which fs-fit runs now, on
|
||||
* every content mutation (synchronously, pre-paint) and on every resize of #view. */
|
||||
fit.add(() => { tagDataTables(); fitTables(); unpinActionColumn(); resyncValues(); });
|
||||
fit.add(() => { tagDataTables(); fitTables(); fitScrollables(); unpinActionColumn(); resyncValues(); });
|
||||
|
||||
/* one scan per frame, however many mutations arrive (fit.frame — the theme's shared
|
||||
* coalescer) */
|
||||
|
||||
Reference in new issue
Block a user