mirror of
https://github.com/kiddin9/op-packages.git
synced 2026-09-11 02:44:57 +08:00
901 lines
46 KiB
JavaScript
901 lines
46 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 cut every listener enhance() installed.
|
|
* resync() calls teardown()+enhance() every time a script rebuilds the option list, which CBI
|
|
* dependencies do constantly, so a surviving listener accumulates one per rebuild, each closing
|
|
* over a dead ui.Dropdown and its detached subtree. 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 shows the old value while Save reads 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) {
|
|
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: `sel.value = x` and
|
|
* `options[i].selected = true` — what `ui.Select.setValue()` does, and form.js calls it on every
|
|
* dependency pass — set no content attribute and add no node, so no MutationRecord exists for
|
|
* relevant() to wake on and the widget shows the old label while Save reads the new value.
|
|
*
|
|
* So this runs from the fitter, once per content mutation batch, and is deliberately the cheap half
|
|
* of resync(): a value compare per enhanced select, no choicesKey() over every option. Re-keying
|
|
* stays behind relevant(), which 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: unmarked, the same select is re-selected on every
|
|
* mutation frame and throws again, forever. One loud failure, then left as a stock
|
|
* <select>. */
|
|
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 and drop the select from the
|
|
* a11y tree */
|
|
const title = sel.closest('.cbi-value')?.querySelector('.cbi-value-title');
|
|
/* a table section has no .cbi-value and no .cbi-value-title — form.js builds a bare
|
|
* `td.cbi-value-field` — so the widget would be left nameless there. The cell's `data-title` is
|
|
* the column heading, which LuCI fills for the card stack. */
|
|
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 the native
|
|
* <select>, which this theme has set `display: none` on, so the click does nothing at all.
|
|
* Focus only, which is the parity stock gets — its `elem.click()` on a `<select>` opens no list
|
|
* either. */
|
|
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-evaluates on every mutation of these polled tables.
|
|
* Not a .cbi-section-table — config forms keep their own layout.
|
|
*
|
|
* `.table`, not `table.table`, and a bare `<table>` counts too: a third-party app may emit a
|
|
* `<div class="table">`, or a table with no LuCI class at all, and neither would be tagged,
|
|
* measured or carded (docs/conventions.md: coverage is a contract). The bare-table half matches
|
|
* nothing on the dev stand — a census over all 196 menu pages found zero — which is the point.
|
|
*
|
|
* No `:not(.fs-dt)`: these tables are polled, and ui.Table.update() and every hand-rolled
|
|
* equivalent replace the rows inside the element they already have. Claiming a table once means the
|
|
* fresh rows arrive without `.tr`/`.td` or `data-title` on a table that may by then be `.fs-stacked`
|
|
* and clipped with no scrollbar. Both functions below are additive, skip what is already done and
|
|
* re-decide nothing.
|
|
*
|
|
* `#view` is not the only place a table lands: `ui.showModal` parks its dialog on `#modal_overlay`,
|
|
* a SIBLING of #view, so a table in the wireless-scan dialog was reached by none of this and stood
|
|
* 56px past the dialog's edge at 390px. So the roots are listed once here and every table query is
|
|
* built from them: the overlay is the scroll container (base/60-modal.css) and theme/70-modal.css
|
|
* already keeps a wide child from scrolling the dialog off the screen, and a table is the widest
|
|
* child there is.
|
|
*
|
|
* A CLOSED dialog is not a content root, though it is still in the DOM: `hideModal()` only drops a
|
|
* class off <body> and the dialog keeps its markup, but the overlay then shrink-fits, so a table
|
|
* inside it would be judged against a width the dialog will never have. `body.modal-overlay-active`
|
|
* is the same fact the CSS uses. The flag flips AFTER the content is written, so this is asked per
|
|
* PASS rather than fixed at load, and fs-fit.js watches that class. */
|
|
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.
|
|
*
|
|
* "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 would otherwise be read as the header row and every value below
|
|
* it captioned with a value. A table with no `<th>` at all returns null and keeps the scroll
|
|
* fallback. */
|
|
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 missing one costs a page: ui.Table emits `.tr.table-titles`,
|
|
* the apk Software page `.tr.cbi-section-table-titles`, a third-party table may use a real
|
|
* `<thead>` (luci-mod-dashboard's are `<thead class="thead">` with `nowrap` cells, which
|
|
* neither card nor compress, so their right-hand columns were clipped on a phone), and
|
|
* headerRow() covers the fourth.
|
|
*
|
|
* `thead`, not `thead tr`: markup built by E() appends the `<th>`s straight to the
|
|
* `<thead>`, so the parser's implied row never happens.
|
|
*
|
|
* Any of the four means a data table; none means a key/value include (System, Memory),
|
|
* which must never card. */
|
|
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 table vocabulary is
|
|
* written against `.table`, so a newly recognised foreign table has to join it. A no-op on
|
|
* everything LuCI renders. */
|
|
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; 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 names. The fitter would
|
|
* then set `.fs-stacked` and change nothing, while `#view .table.fs-dt.fs-stacked` clips the
|
|
* columns with no scrollbar to reach them.
|
|
*
|
|
* `.tr` / `.td` / `.th` are LuCI's own names for these roles (docs/third-party-apps.md), and the
|
|
* theme is already writing `.table` onto the same element. Additive only and cheap to re-run every
|
|
* pass: these tables are polled, so fresh rows arrive bare.
|
|
*
|
|
* The header also has to be recognisable, or the card shows it as a first row of column names: a
|
|
* `<thead>` becomes `.thead`, 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, then read on every pass: asking afresh each pass would answer
|
|
* "already adopted" the moment we adopted it, and the bare rows a poll brings in would never be
|
|
* taken. Asking it at all is what keeps the theme's hands off LuCI's own markup — adding
|
|
* `table-titles` to a `.tr.cbi-section-table-titles` head would rewrite 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> with or without a <tbody> — a table built with createElement
|
|
* has its <tr> directly under the <table>, where `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 builders fill it in; a
|
|
* foreign table has no reason to, and carding one without captions gives a column of values with
|
|
* nothing saying which is which.
|
|
*
|
|
* The heading is copied from the header cell in the same position, never invented, and never
|
|
* overwrites an existing data-title — the app's own answer knows more than a positional guess.
|
|
* Re-run every pass, because these tables are polled and fresh rows arrive bare; the per-row skip
|
|
* below is what keeps that affordable. */
|
|
function labelCells(t, head) {
|
|
const rows = t.querySelectorAll('.tr, tbody tr');
|
|
/* a `<thead>` 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, so reading
|
|
* `head.children` blind captions every cell with the header row's entire text */
|
|
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 mutation pass and everything LuCI renders is captioned by
|
|
* ui.Table as it builds it, so the common case is answered by one attribute read.
|
|
*
|
|
* Per row, because a poll 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 totals while fresh rows keep arriving above it.
|
|
*
|
|
* Its blind spot: the test reads the FIRST cell, so a cell replaced inside a row whose first
|
|
* cell survives stays bare. Nothing in the tree reaches it — ui.Table.update() swaps whole
|
|
* `<tr>`s and hand-rolled equivalents rebuild the row — and testing every cell would cost
|
|
* the walk this skip exists to avoid on every pass of every polled table.
|
|
*
|
|
* A first cell that can never take a caption (spanning, or with empty header text) leaves
|
|
* its row walked every pass. The walk is idempotent, so that is the safe direction. */
|
|
if (cells.length && cells[0].hasAttribute('data-title')) continue;
|
|
/* Column cursor, not the cell index: a cell spanning N columns occupies N header slots while
|
|
* advancing the index by one, so keying titles off `i` captions every cell after it with the
|
|
* heading to its left. The spanning cell itself takes none, having no single heading. (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.) */
|
|
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.
|
|
* Carding by @container needed three thresholds, two of them carrying their own copy of the card
|
|
* rules, and all three were really asking "does it overflow?" — a fact the browser computes. So it
|
|
* is measured, the card rules live once 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, 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 — the firewall zone table then
|
|
* reported needing 1747px against a real 1190px and overflowed its section by 557px. */
|
|
const stackables = () => inRoots('.table.fs-dt', liveRoots());
|
|
|
|
/* "Too cramped to be a table any more" — a design judgement, and the only threshold the fit
|
|
* decision takes on trust rather than measuring. A four-column table of short values still fits at
|
|
* 380px of room, technically, as four ribbons of two words each; nothing measures "unreadable",
|
|
* 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 ----
|
|
*
|
|
* theme/30-tables.css gives a data table `overflow-wrap: break-word` for as long as it is a table,
|
|
* so a column's min-content is an honest floor and the overflow the browser reports is honest with
|
|
* it. (Letting any value break anywhere lowers min-content to one character, so a column is starved
|
|
* instead of the table overflowing and `overflows()` goes blind; reconstructing the floor in JS
|
|
* instead is UAX #14 by hand and wrong in both directions — `WPA2-PSK/CCMP` measured 144px against a
|
|
* real 93px, so tables carded that had room.)
|
|
*
|
|
* That leaves one question — does it fit? — and a ladder of answers when it does not, each
|
|
* 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: the guard is the second measurement. Where one base64 key is the
|
|
* entire problem, breaking that column makes the table fit; where every column is over its share,
|
|
* breaking one changes nothing and the card is right.
|
|
*
|
|
* Rung 2 is upstream's own priority hint, honoured by measurement instead of by viewport: several
|
|
* views mark their least valuable columns `hide-xs` and ui.Table copies the class onto every body
|
|
* cell. */
|
|
|
|
/* Which cells 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. */
|
|
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: never the first, which is the row's identity
|
|
* (issue #36 is that column being starved), and never a `nowrap`/`pre` one, where `overflow-wrap`
|
|
* is inert so the mark would buy a layout and no width.
|
|
*
|
|
* With honest floors nothing is starved, so an overflowing table has every column at its own
|
|
* min-content and the widest one holds the longest unbreakable token — one `getComputedStyle` and
|
|
* one rect per column of one row, no text measurement. */
|
|
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"), which lays the page out with a full-width table for the
|
|
* length of the pass — several screens taller than the card stack it is about to be. The poll
|
|
* REPLACES these tables rather than updating them, so every tick hands the fitter a fresh element
|
|
* with no marks and the intermediate happens again: at iPhone width it threw the reader 612px out
|
|
* and back, twice per tick. Compensating for it afterwards was tried four ways and each was wrong
|
|
* somewhere.
|
|
*
|
|
* So the answer is kept on the SLOT instead — the section frame (`slotHome()` says why not the
|
|
* table's own parent), inside which a table is named by its id, or by its position among that
|
|
* frame's tables.
|
|
*
|
|
* The inherited answer is trusted only while the inputs hold: the tier depends on how many columns
|
|
* share the room and how wide the room is, so both are remembered with it. Room is `roomFor()`,
|
|
* which reads the container rather than the table and is unaffected by the marks; the shape is the
|
|
* column count (`shapeOf()` says why rows are not in it).
|
|
*
|
|
* A change in either re-measures, as does a table arriving in an unmeasured slot, as does every
|
|
* full pass — which happens whenever the reader is not scrolling and is what catches a value that
|
|
* grew long enough to change the tier. */
|
|
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 too and
|
|
* every lookup would miss. What survives a tick is the `.cbi-section` frame. */
|
|
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: how many columns share the room, and how wide the room is — not how
|
|
* many rows there are. A lease list gains and loses a row on almost every tick, so rows in this
|
|
* signature mean the cache never hits and every tick measures from scratch, which is the tall
|
|
* intermediate this exists to avoid.
|
|
*
|
|
* Read from the tree, never from layout: `children.length` is a DOM question and forces nothing. */
|
|
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 with no answer yet: one that has an answer needs nothing, one
|
|
* that does not is held out of the layout by the stylesheet and cannot wait. Neither branch
|
|
* 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 only WRITES: every layout read here forces a
|
|
* synchronous layout, and a poll tick can land mid-flick, so on a phone with ten tables that
|
|
* is ten forced layouts in the middle of a flick — the work iOS holds the main thread back
|
|
* to prevent. */
|
|
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 still goes on mid-scroll, and it must: letting a freshly polled table wait for
|
|
* the scrolling to stop makes the reader watch the tables fold into cards under their
|
|
* thumb, which is the hardest jerk of all. The answer is a write, forces nothing, and keeps
|
|
* the fresh table the shape of the one it replaced. */
|
|
if (fit.scrolling()) {
|
|
/* the column's width, not the window's: they differ by the sidebar, so an 800px window
|
|
* whose column is 520px would clear CRAMPED and leave the table unstacked and clipped.
|
|
* `chrome.contentWidth()` answers from what the last fitter measured, reading no
|
|
* layout. */
|
|
applyDecision(t, known || { stack: chrome.contentWidth() < CRAMPED, drop: false, breakCol: -1 });
|
|
fit.deferMeasurement();
|
|
return;
|
|
}
|
|
|
|
/* the column count, read from the tree: asking forces no layout */
|
|
const shape = shapeOf(t);
|
|
|
|
/* The answer goes on before anything is measured, and that order is the fix: `roomFor()`
|
|
* forces layout, and a fresh table from a poll tick is unmarked when it lands, so measuring
|
|
* first lays the page out with a full-width table — the tall intermediate the engine
|
|
* re-anchors on. 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, so an
|
|
* exact compare misses the cache on a width that has not moved */
|
|
const room = Math.round(fit.roomFor(t));
|
|
if (!(room > 0)) {
|
|
/* Detached, hidden, or a closed dialog: keep the previous answer 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, and a table whose section is collapsed on
|
|
* arrival would otherwise stay hidden after the section is opened, since opening one
|
|
* changes no width this file watches. */
|
|
t.classList.add('fs-fitted');
|
|
return;
|
|
}
|
|
|
|
/* On its first pass the table is not measurable, because this file's own gate hides it:
|
|
* theme/30-tables.css holds a `.fs-dt` out of the layout until something marks it
|
|
* `.fs-fitted`, so the pass reads `scrollWidth: 0` and 0 overflows nothing. Concluding "it
|
|
* fits" and caching that leaves the table at its natural width, clipped by
|
|
* `.fs-main { overflow-x: clip }` with no scrollbar: measured at 768px, 777px of table inside a
|
|
* 712px column, and a page that renders once and stands still never gets a second pass to
|
|
* correct it.
|
|
*
|
|
* So a zero measurement is not an answer: 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,
|
|
* so a table hidden for somebody else's reason 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 slot's answer holds for a table of the same shape in the same room — unless the table
|
|
* has since outgrown it: a poll tick can put longer values into the same columns, and a
|
|
* table that fitted a second ago then stands 1000px wide in an 810px column, clipped with
|
|
* nothing to say so. That question uses the layout this pass already has.
|
|
*
|
|
* The other direction cannot be asked that way. `overflows()` is put to a table already
|
|
* wearing its remedy, and every remedy makes a table fit by construction, so the answer is
|
|
* always yes and a remedy applied once could never be lifted — one long hostname makes
|
|
* Associated Stations drop its `hide-xs` columns, and they stay hidden after that station
|
|
* leaves. An honest measurement needs the marks stripped, which is the visible relayout the
|
|
* deferral above keeps 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 that is
|
|
* the only way it can stop needing the remedy. Rows are counted from the tree. */
|
|
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. Every mark the ladder can
|
|
* write comes off here. */
|
|
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 */
|
|
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 on every tick, and toggling
|
|
* the class each tick invalidates style for every row 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 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 rendering the same thing as `<div class="table">` — by measurement rather
|
|
* than by selector.
|
|
*
|
|
* A scrolling table cannot hold a popup: `overflow-x: auto` computes `overflow-y` to `auto` too
|
|
* (css-overflow-3 §3.1), so it clips everything absolutely positioned inside it, and luci-base's
|
|
* `openDropdown()` sizes an open list against the nearest scroll parent.
|
|
*
|
|
* So the ones that hold CONTROLS stack instead. Their 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, and nothing becomes a scroll container. Doing nothing for them is a real defect —
|
|
* Network -> Diagnostics is three controls in one header-less row needing 338px in 320px, and the
|
|
* Ping button was simply unreachable past `.fs-main`'s clip. The shape is not that page's, and the
|
|
* width that matters is the room rather than the viewport, so this measures instead of naming a
|
|
* page. */
|
|
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 as it was found ----
|
|
*
|
|
* Firefox has made scrollers focusable for years and Chrome since 132, but only when they hold no
|
|
* focusable child, and WebKit has not shipped it at all (bug 190870). So say it in the markup: a
|
|
* tab stop (SC 2.1.1) and a name for what receives focus (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>` keeps its own, because HTML-AAM maps `<td>` to `cell` and
|
|
* `<th>` to `columnheader`/`rowheader` only while the table's role is `table` (or `grid`/
|
|
* `treegrid`), so `group` would drop every cell to generic and leave a screen reader reading a flat
|
|
* run of text.
|
|
*
|
|
* What is removed is what was written, remembered per element rather than inferred from the value:
|
|
* "is it 0 / is it group" cannot tell whose attribute 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 mid-flick once per poll tick — see fitTables(). Put off until the
|
|
* page is still; fs-fit's sampler runs what was deferred once the 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 being 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 — see
|
|
* reach(). `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 it was measured in ----
|
|
*
|
|
* luci-base's form.js (stabilizeActionColumnWidth) measures the widest `td.cbi-section-actions > div`
|
|
* and writes it as an inline `width`/`min-width` on the header, footer and every actions cell,
|
|
* caching it in `data-action-col-width`. Its resize handler deletes only the CACHE, never the inline
|
|
* widths, so the fresh measurement reads the width it pinned last time: the pin feeds itself and can
|
|
* only grow.
|
|
*
|
|
* On a stock theme that is invisible, a config table being a table at every width. This theme cards
|
|
* it under `@container fs-content (max-width: 960px)`, where the actions cell is `flex: 1 1 100%`
|
|
* and its buttons spread across the whole card, so a measurement taken there is the CARD's width —
|
|
* carried into table mode, firewall/zones pins 634px and scrolls sideways by 256px, permanently,
|
|
* against 192px on a fresh load at the same width.
|
|
*
|
|
* So drop the pin whenever the layout it was measured in stops being the layout on screen; upstream
|
|
* re-measures from a clean DOM and pins the right number, and if it does not, the natural width is
|
|
* what we wanted.
|
|
*
|
|
* The key is the ROOM, not the mode: the card getting narrower goes stale the same way, and at 768px
|
|
* (no sidebar, 712px column) against 800px (sidebar back, 520px column) the table is carded on both
|
|
* sides, so a mode test sees no change while a 670px cell sits in a 520px column. A mode change
|
|
* cannot happen without a room change, so the room catches both.
|
|
*
|
|
* The room is the parent's content box, 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. */
|
|
function unpinActionColumn() {
|
|
/* a layout read here lands mid-flick once per poll tick — see fitTables() */
|
|
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;
|
|
/* Claim upstream's resize hook, which under SPA navigation is a leak:
|
|
* stabilizeActionColumnWidth attaches a `resize` listener once per TABLE ELEMENT, guarded by
|
|
* this expando, and nothing removes it. On a stock theme it dies with the document; here the
|
|
* document lives for the session, so every visit to a config page leaves another listener
|
|
* holding another detached table — 1 to 31 listeners over 120 navigations, ~11.8 KB per
|
|
* navigation that never plateaus.
|
|
*
|
|
* Setting the flag first means the listener is never attached, and nothing is lost: the wipe
|
|
* below re-measures from the room instead of 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; a table reached late simply keeps upstream's
|
|
* listener. */
|
|
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 schedules a full
|
|
* scan, and the poll rewrites content on every tick: three document-wide querySelectorAll plus a
|
|
* choicesKey() over every option of every enhanced select, per tick, to discover that nothing
|
|
* 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 */
|
|
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,
|
|
* which an <option> is neither — so the batch is dropped, the widget keeps the old list, and
|
|
* picking from it writes a value the new list does not contain. CBI dependency handling
|
|
* rebuilds option lists constantly. */
|
|
if (m.type === 'childList' && m.target.tagName === 'SELECT')
|
|
return true;
|
|
}
|
|
/* `.table`, not `table.table` — the `.table` half of the selector tagDataTables() uses. Additions
|
|
* only: a select or a table going away costs 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 does only
|
|
* Esc/Enter/Space/arrows.
|
|
*
|
|
* 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; the buffer
|
|
* resets after a pause; the same letter repeated cycles; the label matches first and the value
|
|
* second, so "RU" and "Russia" both find it. Space is excluded — ui.Dropdown binds it to "toggle
|
|
* the focused item" and its handler fires first.
|
|
*
|
|
* 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();
|
|
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;
|
|
|
|
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 rather
|
|
* than swallow 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` is
|
|
* written nowhere but in this module. Armed in fs-fit.js, a document that loaded that file
|
|
* and not this one (the footer requires them separately) hid every table forever. */
|
|
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.
|
|
*
|
|
* Five registrations, not one callback: fs-fit catches per registered fitter, and the first
|
|
* of these walks third-party markup. Bundled, one throw in `tagDataTables()` leaves no table
|
|
* tagged and — with the gate above raised — a page with no tables on it at all. */
|
|
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 is the shared coalescer) */
|
|
const scanSoon = fit.frame(scan);
|
|
new MutationObserver((mutations) => {
|
|
if (relevant(mutations)) scanSoon();
|
|
}).observe(document.body, {
|
|
childList: true, subtree: true,
|
|
/* `disabled` flips and attribute-driven value writes never mutate childList, so watch
|
|
* them for resync()/enhance() */
|
|
attributes: true, attributeFilter: [ 'disabled', 'value', 'selected' ]
|
|
});
|
|
}
|
|
});
|