mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-12 03:14:48 +08:00
1104 lines
64 KiB
JavaScript
1104 lines
64 KiB
JavaScript
'use strict';
|
|
'require baseclass';
|
|
'require ui';
|
|
'require dom';
|
|
'require fs-fit as fit';
|
|
/* for the content column's width without a layout read — see the mid-scroll branch in fitTables */
|
|
'require fs-chrome as chrome';
|
|
|
|
/* Theme plain LuCI <select> fields (ui.Select, widget:'select') by rendering a styled
|
|
* cbi-dropdown beside them — a native <select> popup cannot be CSS-styled.
|
|
*
|
|
* The native <select> stays the form field and MUST remain frameEl.firstChild:
|
|
* ui.Select.getValue() returns `this.node.firstChild.value`. Inserting our widget BEFORE it made
|
|
* getValue read a <div> and return `undefined`, which broke Save. So insert AFTER, and mirror the
|
|
* value both ways. Sharing the frameEl also ties our node to the widget's lifecycle, so a CBI
|
|
* re-render disposes of it — no orphans.
|
|
*
|
|
* Runs theme-wide (required from the footer); watches for selects added later by client CBI. */
|
|
|
|
function readChoices(sel) {
|
|
const choices = {};
|
|
Array.prototype.forEach.call(sel.options, (o) => { choices[o.value] = o.textContent; });
|
|
return choices;
|
|
}
|
|
|
|
/* cheap identity of the option list, to detect a script rebuilding it
|
|
* (select.replaceChildren, dependency-driven re-population, …) */
|
|
function choicesKey(sel) {
|
|
return Array.prototype.map.call(sel.options, (o) => o.value + '\u0000' + o.textContent).join('\u0001');
|
|
}
|
|
|
|
/* undo enhance(): drop the widget, unhide the select, and — critically — cut every listener
|
|
* enhance() installed. The `change` listener used to survive teardown, and resync() calls
|
|
* teardown()+enhance() every time a script rebuilds the option list (CBI dependencies do this
|
|
* constantly on the firewall/network forms) — so the select accumulated one live listener per
|
|
* rebuild, each closing over a dead ui.Dropdown and its detached subtree: a leak that grew with
|
|
* every interaction. AbortController is the only way to drop an anonymous listener. */
|
|
function teardown(sel) {
|
|
if (sel._fsAbort) sel._fsAbort.abort();
|
|
if (sel._fsNode && sel._fsNode.parentNode)
|
|
sel._fsNode.parentNode.removeChild(sel._fsNode);
|
|
delete sel.dataset.fsSelect;
|
|
sel._fsDd = sel._fsNode = sel._fsKey = sel._fsAbort = null;
|
|
sel.removeAttribute('aria-hidden');
|
|
sel.style.display = '';
|
|
}
|
|
|
|
/* keep an enhanced select and its widget in step when a script drives the native element
|
|
* directly: ui.Select.setValue() rewrites value/options WITHOUT dispatching `change`, so
|
|
* enhance()'s mirror never fires and the widget went stale — showed the old value while Save
|
|
* read the new one. */
|
|
function resync(sel) {
|
|
const dd = sel._fsDd;
|
|
if (!dd || !sel._fsNode) return;
|
|
if (sel.disabled) { teardown(sel); return; } /* disabled later: back to native */
|
|
const key = choicesKey(sel);
|
|
if (key !== sel._fsKey) {
|
|
/* option list rebuilt — recreate the widget from the fresh options */
|
|
teardown(sel);
|
|
enhance(sel);
|
|
return;
|
|
}
|
|
if (dd.getValue() !== sel.value)
|
|
dd.setValue(sel.value);
|
|
}
|
|
|
|
/* A VALUE written through the IDL is invisible to every observer there is.
|
|
*
|
|
* `sel.value = x` and `options[i].selected = true` — which is exactly what `ui.Select.setValue()`
|
|
* does, and `form.js`'s `updateDefaultValue()` calls it on every dependency pass — set no content
|
|
* attribute and add no node, so no MutationRecord is produced at all. relevant() therefore could
|
|
* never wake, resync() never ran, and the widget showed the old label while `getValue()` (and Save)
|
|
* read the new one. Reproduced on the router: `s.value = 'DROP'` left the widget unchanged.
|
|
*
|
|
* So this runs from the fitter, i.e. once per content mutation batch — the same cadence the tables
|
|
* already use — and it is deliberately the CHEAP half of resync(): a value compare per enhanced
|
|
* select, no choicesKey() over every option. Re-keying the widget stays behind relevant(), which now
|
|
* sees an option-list rebuild. */
|
|
function resyncValues() {
|
|
for (const sel of document.querySelectorAll('select[data-fs-select]')) {
|
|
const dd = sel._fsDd;
|
|
if (!dd || !sel._fsNode || sel.disabled) continue;
|
|
if (dd.getValue() !== sel.value)
|
|
dd.setValue(sel.value);
|
|
}
|
|
}
|
|
|
|
function enhance(sel) {
|
|
if (sel.dataset.fsSelect || sel.disabled) return; /* disabled: NOT marked — it may be enabled later */
|
|
/* `multiple` and "not in a CBI field" are permanent, so mark it and stop re-testing on
|
|
* every scan */
|
|
if (sel.multiple || !sel.closest('.cbi-value-field, .cbi-value')) {
|
|
sel.dataset.fsSelect = 'skip';
|
|
return;
|
|
}
|
|
|
|
const choices = readChoices(sel);
|
|
|
|
let dd;
|
|
try {
|
|
dd = new ui.Dropdown(sel.value, choices, {
|
|
sort: false,
|
|
optional: Object.prototype.hasOwnProperty.call(choices, '')
|
|
});
|
|
} catch (e) {
|
|
/* Marked, not merely returned from: without the mark the same select is re-selected by
|
|
* scan()'s :not([data-fs-select]) on every mutation frame and throws again, forever and
|
|
* silently. One loud failure, then left as the stock <select> it already is. */
|
|
sel.dataset.fsSelect = 'skip';
|
|
console.error('footstrap: a select could not be enhanced', e);
|
|
return;
|
|
}
|
|
|
|
const node = dd.render();
|
|
const ac = new AbortController();
|
|
sel.dataset.fsSelect = '1';
|
|
sel.style.display = 'none';
|
|
/* The hidden <select> leaves the CBI <label for=…> pointing at something no screen reader
|
|
* announces, and the visible widget nameless. Move the name over, drop the select from the
|
|
* a11y tree. */
|
|
const title = sel.closest('.cbi-value')?.querySelector('.cbi-value-title');
|
|
/* In a TABLE section there is no .cbi-value and no .cbi-value-title at all — form.js builds
|
|
* `E('td', {class: 'td cbi-value-field'})` there — so on firewall zones, port forwards and
|
|
* static leases the widget was left with no accessible name while the native select it replaces
|
|
* is aria-hidden. The cell's `data-title` IS the column heading (LuCI fills it for the card
|
|
* stack), which is the same string the header cell shows. */
|
|
const name = (title && title.textContent.trim()) ||
|
|
(sel.closest('.td')?.getAttribute('data-title') || '').trim();
|
|
if (name)
|
|
node.setAttribute('aria-label', name);
|
|
/* Clicking the field's caption must reach the widget. form.js wires that label to
|
|
* `#widget.cbid…`.click()/focus() — which is the native <select> we just set `display: none` on,
|
|
* so on this theme the click did nothing at all (measured: focus stayed on <body>, no list
|
|
* opened), while stock bootstrap focuses the select. The <label for=…> is equally dead for the
|
|
* same reason. Re-point the gesture at the visible control — focus only, which is the parity
|
|
* stock gets: its `elem.click()` on a `<select>` opens no list either, so the gesture has always
|
|
* meant "put me on this control". */
|
|
if (title)
|
|
title.addEventListener('click', () => node.focus(), { signal: ac.signal });
|
|
sel.setAttribute('aria-hidden', 'true');
|
|
sel._fsDd = dd;
|
|
sel._fsNode = node;
|
|
sel._fsKey = choicesKey(sel);
|
|
sel._fsAbort = ac;
|
|
|
|
/* AFTER the select: it must stay frameEl.firstChild for ui.Select to read its value on save */
|
|
sel.parentNode.insertBefore(node, sel.nextSibling);
|
|
|
|
/* stops our own dd->sel dispatch from echoing back through the sel->dd listener */
|
|
let syncing = false;
|
|
|
|
/* our widget -> native select (user picked an option) */
|
|
node.addEventListener('cbi-dropdown-change', () => {
|
|
const v = dd.getValue();
|
|
if (sel.value === v) return;
|
|
syncing = true;
|
|
sel.value = v;
|
|
sel.dispatchEvent(new Event('change', { bubbles: true }));
|
|
syncing = false;
|
|
}, { signal: ac.signal });
|
|
|
|
/* native select -> our widget (a script/CBI dependency changed and dispatched change on
|
|
* the select) — keeps the visible widget from going stale */
|
|
sel.addEventListener('change', () => {
|
|
if (syncing) return;
|
|
if (dd.getValue() !== sel.value)
|
|
dd.setValue(sel.value);
|
|
}, { signal: ac.signal });
|
|
}
|
|
|
|
/* Tag standalone data tables so the stacking rules key off a static `.fs-dt` instead of a live
|
|
* `: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 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.
|
|
*
|
|
* `.table` is LuCI's own class, and everything the theme knows how to do with a table hangs off it.
|
|
* A third-party app that emits a BARE `<table>` — no LuCI classes at all — therefore matched none of
|
|
* this: nothing tagged it, nothing measured it, nothing carded it, and the only thing that reached it
|
|
* was a phone-tier scrollbar (theme/90-responsive.css). Reported from a phone against a wifi-clients
|
|
* dashboard whose last column was simply cut off.
|
|
*
|
|
* So the second half of this selector claims those too. It is deliberately UNMEASURABLE on the dev
|
|
* stand — a census of `#view table:not(.table):not(.cbi-section-table)` over all 196 menu pages,
|
|
* openclash / justclash / ssclash / dashboard / statistics included, found ZERO. That is the point:
|
|
* every table anyone here emits already carries the class, so this changes nothing that can be
|
|
* measured and covers the one shape that cannot be (docs/conventions.md: coverage is a contract). */
|
|
/* NO `:not(.fs-dt)`, and that is the difference between claiming a table once and KEEPING it. These
|
|
* tables are polled: L.ui.Table.update() and every hand-rolled equivalent replace the rows inside
|
|
* the element they already have. Excluding what we tagged meant the claim ran exactly once per
|
|
* element — the rows present at that moment were adopted and captioned, and every batch after it
|
|
* kept neither `.tr`/`.td` nor `data-title`, on a table that by then may carry `.fs-stacked`, where
|
|
* `#view .table.fs-dt.fs-stacked { overflow: hidden }` clips the lot with no scrollbar. That is the
|
|
* exact failure adoptMarkup() was written to prevent, arriving one poll later. LuCI's own tables
|
|
* were never exposed to it (ui.Table writes those class names and the caption itself), so this is
|
|
* third-party-only — which is the zone this whole selector exists for.
|
|
*
|
|
* 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). */
|
|
/* ---- 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
|
|
* tier's scroll fallback was written against, so it has to be recognisable here or that table can
|
|
* still only ever scroll.
|
|
*
|
|
* "Every cell in the first row is a `<th>`" is the whole test, and it has to be EVERY: a data row
|
|
* whose first cell is a row header (`<th scope=row>`) would otherwise be read as the header row and
|
|
* every value below it captioned with a value. A table with no `<th>` at all — a layout table, a
|
|
* matrix — returns null and keeps today's behaviour, which is what the scroll fallback is for. */
|
|
function headerRow(t) {
|
|
const row = t.rows && t.rows[0];
|
|
if (!row || !row.cells.length) return null;
|
|
return [ ...row.cells ].every((c) => c.tagName === 'TH') ? row : null;
|
|
}
|
|
|
|
function tagDataTables() {
|
|
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
|
|
* table may simply use a real `<thead>` — luci-mod-dashboard's device lists are
|
|
* `<thead class="thead dashboard-bg"><th class="th nowrap">`, matching neither name. They
|
|
* therefore never carded, and because those `th`s are `nowrap` they could not compress
|
|
* either: on a phone the right-hand columns were cut off by .fs-main's overflow clip.
|
|
* Reported from a router with wifi clients.
|
|
*
|
|
* `thead`, not `thead tr`: that markup is built by E(), which appends the `<th>`s straight
|
|
* to the `<thead>` — the parser's implied row never happens, so a `tr` in the selector finds
|
|
* nothing. Read as "the header ROW-ISH element", which is what its children are cells of.
|
|
*
|
|
* ANY of the four = a data table; NONE = a key/value include (System, Memory), which must
|
|
* never card. `thead` is the structural form of the same statement the two classes make, so
|
|
* it belongs in the same list rather than in a rule of its own; headerRow() is the fourth and
|
|
* cannot be, because "the first row is all `<th>`" is not a selector. */
|
|
const head = t.querySelector('.tr.table-titles, .tr.cbi-section-table-titles, thead') || headerRow(t);
|
|
if (!head) return;
|
|
/* `.table` as well as `.fs-dt`, and only ever ADDED: the theme's whole table vocabulary —
|
|
* the frame, the cell padding, the card stack — is written against `.table`, so a foreign
|
|
* table that has just been recognised as a data table has to join it or the tag buys nothing.
|
|
* A no-op on everything LuCI renders, which carries the class already. */
|
|
t.classList.add('table', 'fs-dt');
|
|
adoptMarkup(t, head);
|
|
labelCells(t, head);
|
|
});
|
|
}
|
|
|
|
/* ...AND THE ROWS AND CELLS INSIDE IT, or the claim is a trap.
|
|
*
|
|
* `.table` alone gets the frame and the padding, because those rules end at the table. Everything
|
|
* that makes the CARD is written one level down — `.table.fs-stacked .tr { display: flex }`, the
|
|
* `.td[data-title]::before` label, the hidden header row — and a bare foreign `<table>` carries none
|
|
* of those class names. So the fitter would measure it, decide it no longer fits, set `.fs-stacked`
|
|
* and change NOTHING: measured at 390px on a bare four-column table, the rows stayed `table-row`,
|
|
* the cells `table-cell` at 80px each, no label was generated — and `#view .table.fs-dt.fs-stacked`
|
|
* sets `overflow: hidden`, so the columns were CLIPPED with no scrollbar to reach them. That is
|
|
* worse than the phone-tier scroll it replaced, which is the whole reason this exists.
|
|
*
|
|
* `.tr` / `.td` / `.th` are LuCI's own names for these roles (docs/third-party-apps.md: the shared
|
|
* zone), and the theme is already writing `.table` onto the same element — this is that one act
|
|
* carried down to the rows, not a new liberty. Additive only, and cheap enough to re-run every fit
|
|
* pass: `classList.add` on an element that already has the class is the same `contains` check we
|
|
* would write to skip it, and these tables are POLLED, so fresh rows arrive bare.
|
|
*
|
|
* The HEADER also has to be recognisable as one, or the card shows it as a first row of column
|
|
* names: a `<thead>` becomes `.thead` and a plain first row of `<th>` becomes `.tr.table-titles` —
|
|
* the two names theme/30-tables.css hides when stacked. */
|
|
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
|
|
* 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
|
|
* would be the theme rewriting a class LuCI chose. */
|
|
if (t._fsAdopt === undefined) t._fsAdopt = !t.querySelector('.tr, .thead');
|
|
if (!t._fsAdopt) return;
|
|
if (head.tagName === 'THEAD') head.classList.add('thead');
|
|
else head.classList.add('tr', 'table-titles');
|
|
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
|
|
for (const c of titleRow.children) c.classList.add('th');
|
|
/* `t.rows` covers a real <table> whether or not it has a <tbody> — a table built with
|
|
* createElement has its <tr> directly under the <table> and `tbody tr` finds nothing. A
|
|
* `<div class="table">` has no `.rows` and is LuCI's own markup, which carries the classes. */
|
|
if (!t.rows) return;
|
|
for (const row of t.rows) {
|
|
if (head.contains(row) || row === head) continue;
|
|
row.classList.add('tr');
|
|
for (const cell of row.children) cell.classList.add(cell.tagName === 'TH' ? 'th' : 'td');
|
|
}
|
|
}
|
|
|
|
/* Give every cell the column heading it will show once the table cards.
|
|
*
|
|
* The card layout prints `attr(data-title)` above each value (theme/30-tables.css), and LuCI's own
|
|
* table builders fill that attribute in. A foreign table has no reason to: luci-mod-dashboard emits
|
|
* bare `<td class="td">`, so carding it would have produced a column of values with nothing saying
|
|
* which was the hostname and which the signal — worse than the clipped table it replaced.
|
|
*
|
|
* The heading is COPIED, not invented: it is the text of the header cell in the same position, so
|
|
* the card says exactly what the column header says. Never overwrites an existing data-title — if
|
|
* the app set one, that is the app's answer and it knows more than a positional guess. Re-run on
|
|
* every fit pass, because these tables are POLLED: the rows are replaced wholesale every few
|
|
* seconds and the fresh ones arrive without the attribute. What keeps that affordable is the
|
|
* PER-ROW skip below: a captioned row is recognised from one cell instead of all of them, so a
|
|
* table LuCI captioned itself costs one attribute test per row and nothing else. */
|
|
function labelCells(t, head) {
|
|
const rows = t.querySelectorAll('.tr, tbody tr');
|
|
/* A `<thead>` that was WRITTEN as markup nests a real `<tr>` — the parser inserts one even where
|
|
* the author left it out — while one built by E() holds the `<th>`s directly (see above). Reading
|
|
* `head.children` blind therefore captioned every cell of a parsed table with the header row's
|
|
* ENTIRE text: "HostAddressSignal" over the hostname, over the address and over the signal.
|
|
* Measured against a bare `<table><thead><tr><th>` on the stand, which is the shape a
|
|
* server-rendered or innerHTML-built foreign table has. */
|
|
const titleRow = (head.firstElementChild && head.firstElementChild.tagName === 'TR') ? head.firstElementChild : head;
|
|
const titles = [ ...titleRow.children ].map((c) => (c.textContent || '').trim());
|
|
if (!titles.some(Boolean)) return;
|
|
for (const row of rows) {
|
|
if (row === head) continue;
|
|
const cells = row.children;
|
|
/* ---- SKIP A ROW THAT IS ALREADY CAPTIONED, ASKED OF THE ROW AND OF NOTHING ELSE ----
|
|
*
|
|
* A claimed table is revisited on every fit pass — a MUTATION pass, not a once-a-second one —
|
|
* so the common case has to be answered cheaply: everything LuCI renders is captioned by
|
|
* `ui.Table` as it builds it, and the walk could never find work there. Testing the row's FIRST
|
|
* cell answers that in one attribute read instead of one per cell.
|
|
*
|
|
* The row, rather than the table, is what carries the answer, and that is the whole point: a
|
|
* poll appends or replaces rows one at a time, and a table can hold captioned and bare rows at
|
|
* once. A table-level probe has to guess WHICH row speaks for the rest, and every choice is
|
|
* wrong for some shape — asking the last row stalls forever on a `<tfoot>` of per-column
|
|
* totals, which is captioned once and then never bare again while fresh rows keep arriving in
|
|
* the `<tbody>` above it (`t.rows` spans thead, tbody and tfoot, and the query above is in
|
|
* document order). Asked per row, there is nothing to guess: a fresh row has no caption and is
|
|
* walked, a captioned one is skipped.
|
|
*
|
|
* ITS OWN BLIND SPOT, stated because the reader has no other way to learn its shape: the test
|
|
* reads the FIRST cell, so a cell replaced inside a row whose first cell survives is skipped
|
|
* with the row and stays bare. It is narrower than the table-level probe's — that one stalled
|
|
* a whole table forever, this one misses cells 2..n of one row, and only while that row's first
|
|
* cell keeps its caption — and nothing in the tree reaches it: `ui.Table.update()` swaps whole
|
|
* `<tr>`s, and every hand-rolled equivalent rebuilds the row. Testing every cell instead would
|
|
* cost the walk this skip exists to avoid, on every pass of every polled table, to cover a
|
|
* shape no emitter produces.
|
|
*
|
|
* A first cell that can never take a caption — one that spans columns, or whose header text is
|
|
* empty — leaves its row walked on every pass. That is the safe direction and it is bounded by
|
|
* the row, not the table: the walk is idempotent and writes nothing it has not been asked to. */
|
|
if (cells.length && cells[0].hasAttribute('data-title')) continue;
|
|
/* COLUMN cursor, not the cell index: a cell that spans N columns occupies N of the header's
|
|
* slots while advancing the cell index by one, so keying titles off `i` captioned every cell
|
|
* AFTER a spanning one with the heading of the column to its left — "Hostname" over an IP
|
|
* address, and nothing to say the mapping was guessed. Only the spanning cell itself is left
|
|
* uncaptioned, because it has no single heading to take. (A rowspan reaching down from an
|
|
* earlier row would shift this too; no LuCI table emits one, and a wrong caption is worse
|
|
* than none, so that shape stays unhandled rather than approximated.) */
|
|
let col = 0;
|
|
for (let i = 0; i < cells.length; i++) {
|
|
const span = (cells[i].colSpan > 1) ? cells[i].colSpan : 1;
|
|
if (span === 1 && col < titles.length && titles[col] && !cells[i].hasAttribute('data-title'))
|
|
cells[i].setAttribute('data-title', titles[col]);
|
|
col += span;
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ---- CARD-STACK A DATA TABLE THAT NO LONGER FITS --------------------------------
|
|
*
|
|
* Measuring, scheduling and the observers are fs-fit.js; this file supplies only the DECISION.
|
|
* A data table used to card by @container at THREE thresholds (568 plain, 780 leases, 800 apk
|
|
* package list), the last two each carrying their own COPY of the card rules — CSS cannot share
|
|
* a block across two thresholds. All were really asking "does it OVERFLOW?", a fact the browser
|
|
* computes, so it is measured instead: the card rules live once in theme/30-tables.css on
|
|
* .fs-stacked, and a third-party table of unknowable width works too.
|
|
*
|
|
* A CONFIG table (.cbi-section-table) keeps its @container (960, theme/65-dropdown.css) and must
|
|
* NOT be measured: its rows hold widgets (enhance() above turns every <select> into a
|
|
* ui.Dropdown) and a widget bakes in the width of the layout it was laid out in, so
|
|
* un-collapsing it to read it CHANGES what is read. Measured on the router: the firewall zone
|
|
* 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 stackables = () => inRoots('.table.fs-dt', liveRoots());
|
|
|
|
/* "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 REMEDY LADDER: cheapest first, re-measured at every rung ----
|
|
*
|
|
* 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).
|
|
*
|
|
* 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. */
|
|
|
|
/* 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;
|
|
}
|
|
|
|
/* ---- A TABLE THAT REPLACES A TABLE INHERITS ITS ANSWER ----
|
|
*
|
|
* The measurement below has to strip a table's marks first (fs-fit rule 1: a stacked table is a pile
|
|
* of flex rows and always "fits"), and that lays the page out with a full-width table for the length
|
|
* of the pass — at 390px, several screens taller than the card stack it is about to be. Standing
|
|
* still nobody sees it. The poll REPLACES these tables rather than updating them, so every tick used
|
|
* to hand the fitter a fresh element with no marks and that intermediate happened again; on a remote
|
|
* router at iPhone width it threw the reader 612px out and back, twice per tick.
|
|
*
|
|
* Compensating for it was tried four ways and each was wrong somewhere; the changelog entry for this
|
|
* release records all four, because each looked right. This attacks the cause instead: the
|
|
* intermediate exists only because the answer was thrown away with the element, so the answer is
|
|
* kept on the SLOT. The slot is the section frame — `slotHome()` below says why it cannot be the
|
|
* table's own parent — and inside it a table is named by its id, or by its position among the
|
|
* tables of that frame when it has none.
|
|
*
|
|
* WHEN THE INHERITED ANSWER IS TRUSTED, and it is not "always". The tier depends on how many columns
|
|
* share the room and on how wide the room is, so both are remembered with it. Room is `roomFor()`,
|
|
* which reads the container rather than the table, so it is unaffected by the marks. The shape is
|
|
* the column count, read from the DOM — `shapeOf()` says why rows are deliberately not in it.
|
|
*
|
|
* A change in either one re-measures. So does the arrival of a table in a slot nobody has measured
|
|
* yet, and so does every full pass, which happens whenever the reader is not scrolling — that last
|
|
* one is what catches the case a column count cannot see, a value that grew long enough to change
|
|
* the tier, within a second of it happening and never during a scroll.
|
|
*
|
|
* WHAT IT COSTS. A tick on a table whose columns and room did not change writes its class names and
|
|
* reads one width, against stripping every mark, forcing a layout, reading `scrollWidth` and
|
|
* possibly walking the columns. Mid-scroll it does not even read the width: only tables with no
|
|
* answer at all are visited, and each is given one without measuring anything. */
|
|
const _slots = new WeakMap();
|
|
|
|
/* THE SLOT IS THE FRAME, NOT THE PARENT, and the difference is the whole cache. A poll tick hands
|
|
* the container a freshly rendered subtree, so the table's immediate parent is usually new as well —
|
|
* keyed on that, every lookup missed and every tick measured from scratch, which is exactly the
|
|
* behaviour this was written to end (measured: the ±612px kept coming). What survives a tick is the
|
|
* frame the theme and luci-mod-status build once, `.cbi-section`; inside it a table is named by its
|
|
* id where it has one, and by its position among the tables of that frame where it does not. */
|
|
function slotHome(t) {
|
|
return (t.closest && t.closest('.cbi-section, .cbi-section-node, #view')) || t.parentElement;
|
|
}
|
|
|
|
function slotKey(t, home) {
|
|
if (t.id) return '#' + t.id;
|
|
const kin = home ? Array.from(home.querySelectorAll('.table')) : [];
|
|
const at = kin.indexOf(t);
|
|
return at >= 0 ? 'at' + at : 'lone';
|
|
}
|
|
|
|
/* WHAT CAN CHANGE THE ANSWER, AND WHAT CANNOT. The tier depends on how many columns have to share
|
|
* the room and on how wide the room is — not on how many rows there are. Rows were in this
|
|
* signature first and that was the bug behind a whole afternoon: a lease list gains and loses a row
|
|
* on almost every poll tick, so the signature changed every tick, the cache never hit, and every
|
|
* tick measured from scratch — which is the tall intermediate this exists to avoid.
|
|
*
|
|
* Read from the tree, never from layout: `children.length` is a DOM question, so asking it cannot
|
|
* force the layout the whole design is arranging not to force. */
|
|
function shapeOf(t) {
|
|
const head = t.querySelector('.tr.table-titles, .cbi-section-table-titles') || t.firstElementChild;
|
|
return String(head ? head.children.length : 0);
|
|
}
|
|
|
|
function applyDecision(t, d) {
|
|
/* the mark that lets the table into the layout at all — see theme/30-tables.css */
|
|
t.classList.add('fs-fitted');
|
|
t.classList.toggle('fs-stacked', !!d.stack);
|
|
t.classList.toggle('fs-drop-xs', !!d.drop);
|
|
if (d.breakCol !== undefined && d.breakCol !== -1)
|
|
markBreakColumn(t, t.querySelectorAll('.tr'), d.breakCol);
|
|
}
|
|
|
|
function fitTables() {
|
|
/* MID-SCROLL, ONLY THE TABLES THAT HAVE NO ANSWER YET. A table that already carries its answer
|
|
* needs nothing; one that does not is being held out of the layout by the stylesheet and cannot
|
|
* wait. Neither branch of this function reads layout while `scrolling()` is true. */
|
|
const sel = fit.scrolling() ? inRoots('.table.fs-dt:not(.fs-fitted)', liveRoots()) : stackables();
|
|
document.querySelectorAll(sel).forEach((t) => {
|
|
/* ---- WHILE THE READER SCROLLS, THIS PASS READS NOTHING ----
|
|
*
|
|
* Every layout read here — `roomFor()` is one per table — forces the engine to lay the page
|
|
* out synchronously, and a poll tick lands once a second, so on a phone that is ten forced
|
|
* layouts in the middle of a flick. iOS runs the main thread sparingly during momentum
|
|
* scrolling exactly to keep the frame rate; work like this is what it is protecting against,
|
|
* and the reporter's word for the result was "shakes". A stock theme has no JS in this path
|
|
* at all, which is why the same page under luci-theme-bootstrap scrolls smoothly.
|
|
*
|
|
* So a pass that happens mid-scroll only WRITES: the answer this slot already has goes on the
|
|
* fresh table, and nothing is measured until the scrolling stops. A slot nobody has measured
|
|
* yet is judged from the window width instead of the container's — the one number available
|
|
* without touching layout, and at phone width it gives the same answer. */
|
|
const home = slotHome(t);
|
|
const slots = home ? (_slots.get(home) || new Map()) : null;
|
|
const key = slots ? slotKey(t, home) : null;
|
|
const known = slots ? slots.get(key) : null;
|
|
|
|
/* the answer without a measurement: the slot's, or — for a slot nobody has measured yet —
|
|
* the window's width, which is the one number available without touching layout. At the
|
|
* width where this matters the cheap judgement and the measured one agree. */
|
|
/* MID-SCROLL THE ANSWER STILL GOES ON, and it must. Letting a freshly polled table wait for
|
|
* the scrolling to stop was tried on the device that has the fault: the reader then WATCHES
|
|
* the tables fold into cards under their thumb, and that fold is the hardest jerk of all. The
|
|
* answer is a write, it forces nothing, and it keeps the fresh table the same shape as the one
|
|
* it replaced. */
|
|
if (fit.scrolling()) {
|
|
/* the column's width, not the window's: in the sidebar layout they differ by the sidebar,
|
|
* and an 800px window whose column is 520px used to clear CRAMPED and leave the table
|
|
* unstacked at full width, clipped by `.fs-main { overflow-x: clip }` until the reader
|
|
* held still. `chrome.contentWidth()` answers from what the last fitter measured plus the
|
|
* layout attributes — no layout is read here either. */
|
|
applyDecision(t, known || { stack: chrome.contentWidth() < CRAMPED, drop: false, breakCol: -1 });
|
|
fit.deferMeasurement();
|
|
return;
|
|
}
|
|
|
|
/* the column count, read from the tree: no layout is forced by asking */
|
|
const shape = shapeOf(t);
|
|
|
|
/* ---- THE ANSWER GOES ON BEFORE ANYTHING IS MEASURED, AND THAT ORDER IS THE FIX ----
|
|
*
|
|
* `roomFor()` reads a clientWidth, which forces layout — and a fresh table from a poll tick
|
|
* is UNMARKED when it lands, so a pass that measured first laid the page out with a
|
|
* full-width table before it had said a word. That is the tall intermediate the engine
|
|
* re-anchors on: measured on a remote router at iPhone width, ±612px twice per tick, and it
|
|
* survived every attempt to compensate for it because the compensation ran after the layout
|
|
* that caused it.
|
|
*
|
|
* With the slot's answer applied first, the only layout this pass forces is one where the
|
|
* table already wears the answer it had a second ago. */
|
|
if (known) applyDecision(t, known);
|
|
|
|
const was = t.classList.contains('fs-stacked');
|
|
/* rounded, because `roomFor()` subtracts parsed padding and answers in fractions: comparing
|
|
* those exactly made the cache miss on a width that had not moved at all */
|
|
const room = Math.round(fit.roomFor(t));
|
|
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 — but LET IT INTO THE LAYOUT anyway. The stylesheet
|
|
* keeps an unanswered table out of the flow (theme/30-tables.css), and a table whose
|
|
* section is collapsed when it arrives would otherwise stay hidden after the section is
|
|
* opened: nothing measures it again, because opening a section changes no width this
|
|
* file watches. A table with no room cannot move anything either way, so admitting it
|
|
* costs nothing and the next pass with room decides properly. */
|
|
t.classList.add('fs-fitted');
|
|
return;
|
|
}
|
|
|
|
/* AND THE TABLE ITSELF HAS TO BE MEASURABLE, WHICH ON ITS FIRST PASS IT IS NOT — because
|
|
* THIS FILE'S OWN GATE is what hides it. `theme/30-tables.css` holds a `.fs-dt` out of the
|
|
* layout until something marks it `.fs-fitted`, so the very first pass over a fresh table
|
|
* reads `scrollWidth: 0` — a `display: none` box has no content width — and 0 overflows
|
|
* nothing. The pass therefore concluded "it fits", wrote `.fs-fitted`, and CACHED that
|
|
* answer against the slot; the table then appeared at its natural width, 777px inside a
|
|
* 712px column, columns cut off by `.fs-main { overflow-x: clip }` with no scrollbar and
|
|
* nothing to say so.
|
|
*
|
|
* It usually corrected itself, which is what made it a report rather than a gate failure: a
|
|
* second pass ~60 ms later measured the now-visible table and broke its widest column. But
|
|
* that pass only exists if something mutates `#view` again, and Status → Processes renders
|
|
* once and then stands still. Measured on the stand at 768px, entering the page directly:
|
|
* 2 of 8 arrivals on 0.13.1 and 3 of 8 here left the table past the column, and one
|
|
* mutation of any kind fixed it instantly.
|
|
*
|
|
* So a zero measurement is not an answer to anything: lift the gate so the next frame can
|
|
* see the table, ask for that frame, and write NOTHING to the slot. The schedule is asked
|
|
* for once — on the pass that lifts the gate — so a table hidden for somebody else's reason
|
|
* (a closed section, a tab pane) cannot turn this into a frame loop. */
|
|
if (t.scrollWidth === 0) {
|
|
if (!t.classList.contains('fs-fitted')) {
|
|
t.classList.add('fs-fitted');
|
|
fit.schedule();
|
|
}
|
|
return;
|
|
}
|
|
|
|
/* the answer this slot already has, for a table of the same shape in the same room — UNLESS the
|
|
* table has since outgrown it. Room and column count are not the whole input: a poll tick can
|
|
* put longer values into the same columns, and then a table that fitted a second ago does not.
|
|
* Measured on a live router at 900px, where the cache held: status_leases6 stood 1000px wide in
|
|
* an 810px column and wifi_assoclist_table 896px, neither carded, both quietly clipped by
|
|
* `.fs-main { overflow-x: clip }` — columns cut off with nothing to say so. The extra question
|
|
* is one the pass already has the layout for. */
|
|
/* AND THE OTHER DIRECTION, WHICH THAT QUESTION CANNOT ASK. `overflows()` is put to a table
|
|
* that is already wearing its remedy, and every remedy makes a table fit by construction: a
|
|
* card is a pile of flex rows, `.fs-drop-xs` has hidden the expendable columns, a broken
|
|
* column shreds. So the answer is always "it fits", and a remedy applied once could never be
|
|
* lifted while the room and the column count held still — a one-way ratchet. The case is
|
|
* real: one station with a long hostname makes Associated Stations drop its `hide-xs`
|
|
* columns, and those columns stayed hidden after that station left, on a screen with room to
|
|
* spare and nothing to say a column existed.
|
|
*
|
|
* The only honest measurement is of a table with no remedy on it, and taking that on every
|
|
* tick would mean stripping the marks on every tick — the strip-measure-restore that lands as
|
|
* a visible relayout, which is what the deferral above exists to keep out of a scroll. So the
|
|
* re-decision is asked for by the CONTENT instead: a remedied table whose row count has gone
|
|
* DOWN has lost something, and losing something is the only way it can stop needing the
|
|
* remedy. Rows are counted from the tree, not the layout. A table that keeps its rows keeps
|
|
* its answer, which is what makes the poll cheap. */
|
|
const rows = t.querySelectorAll('.tr').length;
|
|
const remedied = !!(known && (known.stack || known.drop || known.breakCol !== -1));
|
|
const shrank = remedied && known.rows !== undefined && rows < known.rows;
|
|
if (known && known.room === room && known.shape === shape && !shrank && !fit.overflows(t))
|
|
return;
|
|
|
|
/* 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. 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);
|
|
|
|
/* 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;
|
|
}
|
|
|
|
t.classList.add('fs-fitted');
|
|
/* 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');
|
|
/* 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');
|
|
|
|
if (slots) {
|
|
slots.set(key, {
|
|
stack,
|
|
drop: t.classList.contains('fs-drop-xs'),
|
|
breakCol: (t._fsBreakCol === undefined) ? -1 : t._fsBreakCol,
|
|
room,
|
|
shape,
|
|
/* what the answer was decided over — a drop below this is the one signal that a
|
|
* remedy may no longer be needed (see the fast path above) */
|
|
rows,
|
|
});
|
|
_slots.set(home, slots);
|
|
}
|
|
});
|
|
}
|
|
|
|
/* ---- 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.
|
|
*
|
|
* 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() {
|
|
/* A layout read here lands in the middle of a flick once per poll tick, and that is the work iOS
|
|
* holds the main thread back to prevent — the same argument fitTables() makes above, and the
|
|
* reason the theme shook on a phone while a stock one did not. Put off until the page is still;
|
|
* fs-fit's sampler runs what was deferred the moment the scroll offset stops changing. */
|
|
if (fit.scrolling()) { fit.deferMeasurement(); return; }
|
|
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, and the
|
|
* tab stop, the name and the conditional role that say so are all reach()'s business —
|
|
* its header above carries the reasoning.
|
|
*
|
|
* `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. */
|
|
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
|
|
* `td.cbi-section-actions > div` and writes that number as an INLINE `width` and `min-width` onto the
|
|
* header cell, the footer cell and every actions cell, caching it in `data-action-col-width`. It does
|
|
* re-run on window resize — but it only deletes the CACHE, never the inline widths, so the fresh
|
|
* measurement reads the width it pinned last time. The pin feeds itself and can only ever grow.
|
|
*
|
|
* On a stock theme that is invisible: a config table is a table at every width, so every measurement
|
|
* is taken in the same layout. This theme cards it under `@container fs-content (max-width: 960px)`
|
|
* (theme/65-dropdown.css), where the actions cell is `flex: 1 1 100%` and its buttons deliberately
|
|
* spread across the whole card — so a measurement taken there is the CARD's width, and carrying it
|
|
* into table mode makes the column absurd.
|
|
*
|
|
* Measured on the router, Network -> Firewall -> Zones: loaded at 1000px (carded) and grown to
|
|
* 1280px, the actions column pins 634px, the table renders 1267px inside a 1056px content column and
|
|
* the column scrolls sideways by 256px — permanently, because upstream's own re-measure reads the
|
|
* pin. A FRESH load at 1280px renders the same table at 966px with a 192px actions column. Shrinking,
|
|
* and growing within table mode, were always fine; it is the card -> table crossing that breaks.
|
|
*
|
|
* So drop the pin whenever the layout it was measured in stops being the layout on screen. Upstream
|
|
* re-measures from a clean DOM on its own resize listener and pins the right number; if it does not,
|
|
* the natural width is what we wanted anyway.
|
|
*
|
|
* THE KEY IS THE ROOM, NOT THE MODE, and starting from the mode alone missed half of it: the card ->
|
|
* table crossing is one way a pin goes stale, and the card simply getting NARROWER is the other. At
|
|
* 768px this theme has no sidebar (data-narrow) and the column is 712px; at 800px the sidebar returns
|
|
* and the column is 520px — the viewport grew, the room shrank, and the table was carded on both
|
|
* sides, so a mode test sees no change at all. Measured: firewall/zones and wireless both kept a
|
|
* `min-width: 670px` cell in a 520px column, 154px of scroll. Keying on the room catches both, since
|
|
* a mode change cannot happen without one.
|
|
*
|
|
* The room is the parent's content box (fit.roomFor), which the table's own width does not feed back
|
|
* 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() {
|
|
/* A layout read here lands in the middle of a flick once per poll tick, and that is the work iOS
|
|
* holds the main thread back to prevent — the same argument fitTables() makes above, and the
|
|
* reason the theme shook on a phone while a stock one did not. Put off until the page is still;
|
|
* fs-fit's sampler runs what was deferred the moment the scroll offset stops changing. */
|
|
if (fit.scrolling()) { fit.deferMeasurement(); return; }
|
|
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 ----
|
|
*
|
|
* stabilizeActionColumnWidth ends by attaching `window.addEventListener('resize', …)` once per
|
|
* TABLE ELEMENT, guarded by this expando, and the callback closes over that element. Nothing
|
|
* ever removes it. On a stock theme the next page is a full load and the listener dies with the
|
|
* document; here the document lives for the whole session, so every visit to a config page
|
|
* leaves another listener holding another detached table.
|
|
*
|
|
* Measured on the router over 120 navigations: window went from 1 resize listener to 31, and a
|
|
* heap snapshot 280 navigations wide grew by 26 880 UniqueElementData, 23 600 Text nodes,
|
|
* 18 520 EventListener and 1 160 <form> — a straight 11.8 KB per navigation that never
|
|
* plateaus once the module cache is full.
|
|
*
|
|
* Setting the flag before upstream reaches it means the listener is never attached, and nothing
|
|
* is lost: what it existed to do — re-measure the column when the width changes — is what the
|
|
* wipe below now does, from the room rather than from a window event. The fitter runs
|
|
* SYNCHRONOUSLY on the mutation batch that inserts the table (fs-fit rule 2), which is what
|
|
* makes claiming it in time possible at all; a table we somehow reach late simply keeps
|
|
* upstream's listener, i.e. today's behaviour. */
|
|
t.__actionColResizeAttached = true;
|
|
const key = Math.round(fit.roomFor(t));
|
|
if (t._fsActRoom === key) continue;
|
|
const seen = (t._fsActRoom !== undefined);
|
|
t._fsActRoom = key;
|
|
/* the first sighting is not a CHANGE: nothing has been pinned in another layout yet */
|
|
if (!seen) continue;
|
|
delete t.dataset.actionColWidth;
|
|
t.querySelectorAll('.cbi-section-actions').forEach((el) => {
|
|
el.style.removeProperty('width');
|
|
el.style.removeProperty('min-width');
|
|
});
|
|
}
|
|
}
|
|
|
|
/* Does this batch contain anything we could care about? Without it EVERY mutation scheduled a
|
|
* full scan — and the poll rewrites content once a second, so on Overview/Processes/Leases we
|
|
* ran three document-wide querySelectorAll plus a choicesKey() over every option of every
|
|
* enhanced select (thousands of characters on the firewall page) every second, forever, to
|
|
* discover that nothing had changed. */
|
|
function relevant(mutations) {
|
|
/* attributeFilter narrows the ATTRIBUTE, not the element: `value`/`disabled` live on inputs
|
|
* and buttons too, and a poll rewriting an input's value would otherwise wake the whole
|
|
* scan. This half is ours alone; the added-node walk below is fs-fit's shared one. */
|
|
for (const m of mutations) {
|
|
if (m.type === 'attributes' && m.target.tagName === 'SELECT')
|
|
return true;
|
|
/* …and a REBUILT OPTION LIST. `sel.replaceChildren(new Option(…))` puts <option> elements in
|
|
* addedNodes, and the shared walk below asks whether an added node IS or CONTAINS a select —
|
|
* an <option> is neither, so the batch was dropped and resync() never re-keyed the widget.
|
|
* Measured on the firewall page: after replaceChildren the native select listed AAA/BBB
|
|
* while the widget still offered reject/drop/accept, and picking from the stale list wrote a
|
|
* value the new list does not contain, i.e. `''`. CBI dependency handling rebuilds option
|
|
* lists constantly, which is the case resync() was written for. */
|
|
if (m.type === 'childList' && m.target.tagName === 'SELECT')
|
|
return true;
|
|
}
|
|
/* `.table`, not `table.table` — the same selector tagDataTables() and STACKABLE use.
|
|
* Additions only: a select or a table going away costs us nothing to notice. */
|
|
return fit.touches(mutations, 'select.cbi-input-select, .table');
|
|
}
|
|
|
|
/* ---- TYPE-AHEAD: jump to an option by typing its first letters ---------------------
|
|
*
|
|
* A native <select> gives this for free, and it is the only way anyone picks a country out of
|
|
* 248 entries. enhance() hides the native select, and ui.Dropdown.handleKeydown (luci-base) does
|
|
* only Esc/Enter/Space/arrows — no letter search — so Wireless -> Country Code became 248 items
|
|
* you could only scroll. (Stock LuCI never had it either; bootstrap only appears to, because it
|
|
* leaves that field a real <select>.)
|
|
*
|
|
* One document-level listener (a dropdown's <ul> holds focus while open), for EVERY
|
|
* .cbi-dropdown — ours and LuCI's own. Native semantics: only while OPEN; printable keys, no
|
|
* modifiers; buffer resets after a pause; the SAME letter repeated cycles (how you reach the
|
|
* second "Germany"); matches the LABEL first, then the value, so "RU" and "Russia" both find it.
|
|
* SPACE is deliberately excluded: ui.Dropdown binds it to "toggle the focused item" and its
|
|
* handler fires first, so treating it as a character would select something.
|
|
*
|
|
* Only HIGHLIGHTS (setFocus, as the arrows do); Enter/Esc stay ui.Dropdown's. */
|
|
const TYPEAHEAD_RESET_MS = 1000;
|
|
let _taBuf = '', _taTimer = null, _taLast = null;
|
|
|
|
function typeaheadItems(sb) {
|
|
const ul = sb.querySelector('ul.dropdown') || sb.querySelector('ul');
|
|
if (!ul) return [];
|
|
return [...ul.children].filter((li) =>
|
|
li.tagName === 'LI' &&
|
|
/* the "custom value" row (options.create) is an input, not a choice */
|
|
!li.querySelector('input:not([type="hidden"])') &&
|
|
li.getClientRects().length > 0);
|
|
}
|
|
|
|
function typeaheadLabel(li) {
|
|
return (li.textContent || '').trim().toLowerCase();
|
|
}
|
|
|
|
function wireTypeahead() {
|
|
document.addEventListener('keydown', (ev) => {
|
|
if (ev.ctrlKey || ev.altKey || ev.metaKey) return;
|
|
if (!ev.key || ev.key.length !== 1 || ev.key === ' ') return;
|
|
/* the create-item input is a text field — let the user type into it */
|
|
if (ev.target && ev.target.matches && ev.target.matches('input, textarea')) return;
|
|
|
|
const sb = ev.target.closest?.('.cbi-dropdown[open]');
|
|
if (!sb) return;
|
|
|
|
const items = typeaheadItems(sb);
|
|
if (!items.length) return;
|
|
|
|
/* a new dropdown starts a new search, however fast the user got here */
|
|
if (sb !== _taLast) { _taBuf = ''; _taLast = sb; }
|
|
|
|
const ch = ev.key.toLowerCase();
|
|
/* repeating one letter cycles; anything else extends the search */
|
|
const repeat = (_taBuf.length === 1 && _taBuf === ch);
|
|
const needle = repeat ? ch : (_taBuf + ch);
|
|
|
|
const start = items.findIndex((li) => li.classList.contains('focus'));
|
|
/* on a repeat, look AFTER the current item so the same letter walks forward; otherwise
|
|
* the search restarts from the top, as a native select does */
|
|
const from = repeat ? start + 1 : 0;
|
|
|
|
/* matches the LABEL first, then the value, so "RU" and "Russia" both find it */
|
|
const matches = (n) => (li) => typeaheadLabel(li).startsWith(n) ||
|
|
String(li.getAttribute('data-value') || '').toLowerCase().startsWith(n);
|
|
const match = matches(needle);
|
|
|
|
/* wrap around: the second pass covers what the first skipped */
|
|
let hit = items.slice(from).find(match) ?? items.find(match);
|
|
if (!hit && !repeat) {
|
|
/* the extended buffer matches nothing — treat this keystroke as a fresh search
|
|
* instead of swallowing it, so a mistyped letter is recoverable */
|
|
hit = items.find(matches(ch));
|
|
if (hit) _taBuf = '';
|
|
}
|
|
if (!hit) return;
|
|
|
|
_taBuf = repeat ? ch : (_taBuf + ch);
|
|
if (_taTimer) window.clearTimeout(_taTimer);
|
|
_taTimer = window.setTimeout(() => { _taBuf = ''; _taLast = null; }, TYPEAHEAD_RESET_MS);
|
|
|
|
/* the widget's own highlighter: adds .focus, scrolls the item into view and focuses it,
|
|
* so Enter (ui.Dropdown's handler) commits exactly what is highlighted */
|
|
const inst = dom.findClassInstance(sb);
|
|
if (inst && typeof inst.setFocus === 'function')
|
|
inst.setFocus(sb, hit, true);
|
|
else
|
|
hit.focus();
|
|
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
});
|
|
}
|
|
|
|
return baseclass.extend({
|
|
__init__() {
|
|
wireTypeahead();
|
|
|
|
const scan = () => {
|
|
document.querySelectorAll('select.cbi-input-select:not([data-fs-select])').forEach(enhance);
|
|
document.querySelectorAll('select.cbi-input-select[data-fs-select="1"]').forEach(resync);
|
|
};
|
|
scan();
|
|
|
|
/* ARM THE STYLESHEET'S GATE FROM HERE, because this is the file that clears it. The rule in
|
|
* theme/30-tables.css holds an unanswered data table out of the layout, and `.fs-fitted` —
|
|
* the answer — is written nowhere but in this module. Arming it in fs-fit.js meant a document
|
|
* that loaded fs-fit and not this file (the footer requires them separately) hid every table
|
|
* forever. Raised here, one line before the pass that clears it is registered. */
|
|
fit.armGate();
|
|
|
|
/* A table must be TAGGED .fs-dt before it can be fitted, and re-tagged whenever the poll
|
|
* brings a fresh one back — so tagging leads, and the passes that depend on it follow.
|
|
*
|
|
* FIVE REGISTRATIONS, NOT ONE CALLBACK. fs-fit catches per registered fitter, so a throw in
|
|
* one of these used to take the other four with it — and the first of them walks third-party
|
|
* markup, which is the shape most likely to surprise it. Bundled, a single throw in
|
|
* `tagDataTables()` left no table tagged, no table answered, and — with the gate above raised
|
|
* — a page with no tables on it at all. Registered separately, each pass fails alone. */
|
|
fit.add(tagDataTables);
|
|
fit.add(fitTables);
|
|
fit.add(fitScrollables);
|
|
fit.add(unpinActionColumn);
|
|
fit.add(resyncValues);
|
|
|
|
/* one scan per frame, however many mutations arrive (fit.frame — the theme's shared
|
|
* coalescer) */
|
|
const scanSoon = fit.frame(scan);
|
|
new MutationObserver((mutations) => {
|
|
if (relevant(mutations)) scanSoon();
|
|
}).observe(document.body, {
|
|
childList: true, subtree: true,
|
|
/* `disabled` flips and attr-driven value writes never mutate childList;
|
|
* watch them so resync()/enhance() notice (filtered — cheap) */
|
|
attributes: true, attributeFilter: [ 'disabled', 'value', 'selected' ]
|
|
});
|
|
}
|
|
});
|