Files
op-packages/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js
T

630 lines
32 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
'require baseclass';
'require ui';
'require fs-fit as fit';
'require fs-prefs as prefs';
'require fs-menutree as tree';
/* The chrome around the content: the mode menu, the section tabs, the rail toggle and the
* measurements deciding how much room each gets. The main menu is injected by menu-footstrap.js as
* a callback (renderMainMenu), since a required LuCI module is a singleton and a renderer cannot
* subclass the chrome (docs/conventions.md). */
/* the injected main-menu renderer; handed over once by the theme's init() */
let _renderMain = null;
function setRenderMain(fn) {
_renderMain = fn;
}
/* section tabs -> #tabmenu (horizontal) */
function renderTabMenu(node, url, level) {
const container = document.querySelector('#tabmenu');
/* a template without the container must not reject: an unhandled rejection here kills the
* whole ui.menu.load() chain, i.e. every menu */
if (!container)
return E([]);
const ul = E('ul', { 'class': 'tabs' });
const children = ui.menu.getChildren(node);
let activeNode = null;
children.forEach((child) => {
const isActive = (L.env.dispatchpath[3 + (level || 0)] === child.name);
/* aria-current, not just the `active` class, which is paint a screen reader cannot see.
* E() drops a null attribute value, so inactive tabs carry nothing. */
ul.appendChild(E('li', { 'class': 'tabmenu-item-%s %s'.format(child.name, isActive ? 'active' : '') }, [
E('a', { 'href': L.url(url, child.name), 'aria-current': isActive ? 'page' : null }, [ _(child.title) ])
]));
if (isActive)
activeNode = child;
});
if (ul.children.length === 0)
return E([]);
container.appendChild(ul);
container.style.display = '';
if (activeNode)
renderTabMenu(activeNode, url + '/' + activeNode.name, (level || 0) + 1);
return ul;
}
/* ---- tab-strip auto-fit ----
* A tab strip (#tabmenu, or a view's own .cbi-tabmenu) can carry ~11 pills (luci-app-justclash)
* that overflow one row. Rather than wrap, shrink: two density classes (theme/40-tabs.css) trim
* padding, then gap+font. Floored so a pill never gets tighter than its label; past the floor the
* strip may wrap. */
function stripFitsOneRow(ul) {
/* only laid-out children count: a display:none child has offsetTop 0, so taking it as `last`
* reads as "one row" on a strip that has wrapped */
const items = [...ul.children].filter((el) => el.getClientRects().length > 0);
const first = items[0], last = items[items.length - 1];
/* one row iff first and last item share a top edge */
return !first || !last || first.offsetTop === last.offsetTop;
}
function fitTabStrips() {
/* `.fs-sidebar > ul.nav` is the main menu in every layout, so the flexDirection check below
* is what tells a bar (row) from a vertical sidebar (column), where a one-row measure is
* meaningless */
document.querySelectorAll('.tabs, .cbi-tabmenu, .fs-sidebar > ul.nav').forEach((ul) => {
if (ul.children.length < 2) return;
if (ul.matches('.fs-sidebar > ul.nav') && getComputedStyle(ul).flexDirection !== 'row') {
/* vertical list: the measure would floor it at fs-dense2 forever */
if (ul.classList.contains('fs-dense1') || ul.classList.contains('fs-dense2'))
ul.classList.remove('fs-dense1', 'fs-dense2');
return;
}
/* steady state (poll tick on an already-fitting strip): one measure, no class writes —
* the write-measure-write below forces a reflow per strip, on every tick */
if (!ul.classList.contains('fs-dense1') && !ul.classList.contains('fs-dense2') && stripFitsOneRow(ul))
return;
ul.classList.remove('fs-dense1', 'fs-dense2');
if (stripFitsOneRow(ul)) return;
ul.classList.add('fs-dense1');
if (stripFitsOneRow(ul)) return;
ul.classList.remove('fs-dense1');
ul.classList.add('fs-dense2'); /* floor: leave wrapped if it still overflows */
});
}
/* ---- does the content column still have room, once the sidebar has taken its cut? ----
*
* The sidebar gives way to the bar when what is left for the content would be too narrow to read.
* A viewport breakpoint cannot say that: the cut is 224px expanded and 68px as a rail, so one
* breakpoint gives both states the same answer and the rail folds away at the same width as the
* full sidebar. Do not measure the RENDERED sidebar either — the answer would depend on the state
* it is deciding (as a bar there is no cut, so the content fits, so it un-narrows) and oscillate.
*
* The widths come from the stylesheet (02-tokens.css) and are never restated here, or narrowing
* the rail in CSS leaves this subtracting the old width with no gate able to see it.
*
* A custom property is untyped, so `parseFloat(getComputedStyle(root).getPropertyValue(…))` reads
* `calc(224px * 1)` and returns NaN — silently, since `NaN < NaN` is false and the sidebar simply
* never yields. Assign the token to a real length property on a throwaway element and read the
* used value back instead. */
let _probe = null;
/* The probe is a plain <div> in the shared document, so a third-party app's CSS can style it —
* it carries no chrome mark and the fence deliberately does not spare it. Every declaration is
* therefore !important, which a style-attribute wins outright: an app carrying
* `div { min-width: 500px !important }` otherwise wins every read and the sidebar folds into a bar
* on a 1857px desktop (issue #19) — the cut becomes 500 + 500 + 1000 = 2000 CSS px, which is why it
* was reported as a zoom bug: Chrome at 90% gives 2063 CSS px and passes, 100% gives 1857 and
* fails. box-sizing is stated for the same reason — getComputedStyle().width is
* the content box, so a foreign `border-box` plus padding would shave the reading. */
function resolveLen(token, dflt) {
if (!_probe) {
_probe = document.createElement('div');
_probe.setAttribute('aria-hidden', 'true');
/* out of flow, no box, no ink: it must never affect layout, scroll extent or hit-testing */
_probe.style.cssText = 'position:absolute!important;visibility:hidden!important;' +
'pointer-events:none!important;height:0!important;box-sizing:content-box!important;' +
'min-width:0!important;max-width:none!important;border:0!important;' +
'padding:0!important;margin:0!important;';
document.body.appendChild(_probe);
}
_probe.style.setProperty('width', 'var(' + token + ')', 'important');
const v = parseFloat(getComputedStyle(_probe).width);
return Number.isFinite(v) ? v : dflt;
}
/* Memoised because fitShell runs on every resize and mutation and resolving forces a style recalc,
* but keyed on the density: that is the one thing changing these widths at runtime
* (`prefs.applyDensity()` stamps `:root[data-density]` and calls fit.schedule() for exactly that).
*
* The defaults are stated once so the fallbacks and the sanity net below cannot restate the
* stylesheet's widths in two places. Reaching for them means the measurement failed. */
const GEOM_DFLT = { contentMin: 500, sidebarW: 224, railW: 68, contentPad: 56, contentMax: 1280 };
/* the class name toggled below by fitShell's own escalation and by fitCluster's (measured: 16 B x3
* -> 25 B, 23 B saved) */
const CLASS_IND_COMPACT = 'fs-ind-compact';
let _geom = null, _geomDensity = null, _geomWarned = false;
function shellGeometry() {
const density = document.documentElement.getAttribute('data-density') || '';
/* contentMax moves with data-content-width (02-tokens.css), the other three widths do not, but
* they are one memo and the axis's own applier calls fit.schedule() same as Density's — folded
* into the same key rather than a second one, or a content-width change alone would hit the
* density-only key and go on answering the previous contentMax until density also changed */
const cwidth = document.documentElement.getAttribute('data-content-width') || '';
const key = density + '|' + cwidth;
/* the gutter is re-asked even on a memo hit: it moves with the width, not the density */
if (_geom && _geomDensity === key) return _geom;
_geomDensity = key;
const px = (name, dflt) => resolveLen(name, dflt);
const g = {
contentMin: px('--fs-content-min', GEOM_DFLT.contentMin),
sidebarW: px('--fs-sidebar-w', GEOM_DFLT.sidebarW),
railW: px('--fs-rail-w', GEOM_DFLT.railW),
/* the token is one side's padding; the column loses it twice. It is only the fallback —
* measureShell() overwrites this with the gutter the column actually got, which nothing
* has measured before the first fitter (and the login page has no `.fs-content`). */
contentPad: px('--fs-content-pad', GEOM_DFLT.contentPad / 2) * 2,
/* where the column stops growing, i.e. where surplus becomes margin — see columnWidth() */
contentMax: px('--fs-content-max', GEOM_DFLT.contentMax)
};
/* Plausibility, at the cost of one comparison: the rail is the sidebar collapsed, so
* 0 < railW < sidebarW holds by construction. Both known failures destroy it — a hijacked probe
* reports one foreign width for all four (issue #19), a renamed or absent token reports 0 for
* all four (an abs-positioned empty div shrinks to 0, which is finite, so the per-read fallback
* never fires). Only the relation between the numbers gives either away. */
const sane = (g.railW > 0 && g.railW < g.sidebarW && g.contentMin > 0);
/* The fallback keeps the chrome laid out, but on built-in literals, so the sidebar folds at a
* width nobody chose while the page still looks correct. Say so, or a renamed token ships
* green. Once per document — this runs on every resize and mutation. */
if (!sane && !_geomWarned) {
_geomWarned = true;
console.error('footstrap: the chrome widths did not read back from the stylesheet (got '
+ JSON.stringify(g) + ') — falling back to the built-in defaults. A --fs-* width token was '
+ 'renamed, or a foreign sheet is reaching the measurement probe.');
}
_geom = sane ? g : Object.assign({}, GEOM_DFLT);
if (_shellPad != null) _geom.contentPad = _shellPad;
return _geom;
}
/* The window's width and the column's gutter are read here, from a fitter or from
* `contentWidth()`'s own staleness check below — nowhere else.
*
* The gutter is measured where it is applied rather than read off `--fs-content-pad`: below 767px
* `theme/20-shell.css` re-pads `.fs-content` to `var(--fs-space-4)`, so the token says 28px a side
* while the real gutter is 16px. The breakpoint may not be restated here — a width literal in JS is
* what these reads exist to avoid — so the element is asked what it actually got.
*
* `clientWidth` is a layout read and `getComputedStyle` resolves style, so a fitter is the one
* caller allowed to reach BOTH unconditionally: it defers the whole pass during a flick
* (`fit.scrolling()`, fs-fit.js) rather than pay either mid-scroll. `contentWidth()` reaches only
* the first (comparison, not the write) on every call, and this function's own body only when
* that comparison says the width moved — its answer is otherwise the geometry as of the last still
* moment, exactly what a fitter last measured.
*
* A hostile declaration is no threat here: `.fs-content` carries no chrome mark, so if an app
* re-pads it then that padding IS the column's gutter. Before any fitter has run — the login page
* has no `.fs-content` — the token stands in. */
let _shellOuter = 0, _shellPad = null, _padAt = null;
function measureShell() {
/* the window's own width, every time: everything downstream is measured against it */
_shellOuter = document.documentElement.clientWidth;
/* The gutter is resolved style and this runs on every mutation batch, so it is memoised on the
* three things that move it: the width (a media query re-pads the column below 767px), the
* density (the token is a calc over it) and the page. The page is the third term because a
* foreign sheet may re-pad `.fs-content`, and `sheets.scopeToCurrentPage()` enables and
* disables those sheets per navigation with no width or density change to notice it by;
* `body[data-page]` is the one attribute a navigation always restamps. */
const key = (document.documentElement.getAttribute('data-density') || '') + '|' + _shellOuter +
'|' + (document.body ? document.body.getAttribute('data-page') || '' : '');
if (_padAt === key && _shellPad != null) return;
const host = document.querySelector('.fs-content');
const cs = host ? getComputedStyle(host) : null;
const v = cs ? parseFloat(cs.paddingLeft) + parseFloat(cs.paddingRight) : NaN;
/* a failed read leaves the key unset, so the next pass retries rather than caching a miss */
if (Number.isFinite(v) && v >= 0) {
_shellPad = v;
_padAt = key;
if (_geom) _geom.contentPad = v;
}
}
function columnWidth(g, state) {
/* narrow OR top: in both the chrome is above the content, not beside it */
const cut = (state.narrow || state.top) ? 0 : (state.rail ? g.railW : g.sidebarW);
/* The column stops growing: `.fs-content` is `max-width: var(--fs-content-max); margin: 0
* auto`, so past ~1500px the surplus becomes margin, and without the cap this answers ~2280 on
* a 2560px window for a column that is 1224 wide. No caller can reach that today (both ask a
* lower bound), but this is the exported answer to "how wide is the content column". */
const room = Math.min(state.outerW - cut, g.contentMax);
return Math.max(0, room - g.contentPad);
}
function fitShell() {
const root = document.documentElement;
/* runs in both branches: the bar layout decides nothing here, but `contentWidth()` still
* answers in it */
measureShell();
if (prefs.currentLayout() === 'top') { /* no sidebar, no cut, nothing to decide */
root.removeAttribute('data-narrow');
return;
}
const g = shellGeometry();
/* asked uncollapsed: this measurement decides `data-narrow`, so it may not read it */
const content = columnWidth(g, { outerW: _shellOuter, rail: prefs.currentRail() });
/* toggleAttribute, not setAttribute: a same-value setAttribute still queues a mutation record,
* while toggleAttribute on an already-present attribute queues none. fitShell runs on every
* mutation batch inside #view, and menu-footstrap treats each data-narrow record as a mode
* change, so on a phone (where the attribute is permanently set) every poll tick would re-fire
* closeFlyouts() and snap shut the section the user just opened. */
root.toggleAttribute('data-narrow', content < g.contentMin);
}
function fitChrome() {
/* nothing asked here — bar width, menu width, room beside the brand — can change while the
* reader scrolls, and each is a layout read landing mid-flick: defer (fs-fit.js) */
if (fit.scrolling()) {
fit.deferMeasurement();
return;
}
fitShell();
const bar = document.querySelector('.fs-sidebar');
const menu = document.getElementById('topmenu');
/* The top bar is measured at every width, with no breakpoint floor: the shrink/compact/stack
* escalation below runs at any width. (The sidebar layout has its own phone bar, decided by
* fitShell's data-narrow, and is untouched here.) */
const topBar = !!bar && !!menu && prefs.isTopLayout();
/* THE BAR MAY NOT CHANGE HEIGHT WHILE IT IS BEING MEASURED, IN EITHER DIRECTION. The three
* classes below are taken off so the menu can be asked whether it fits on one row (fs-fit
* rule 1), and while they are off the bar's OWN box is free to answer any height its content
* currently needs — not only shorter. A `min-height` floor alone stops the shrink but not the
* grow: with the classes off and `fs-dense1`/`fs-dense2` stripped by `fitTabStrips()`, this pass
* measured the bar walking 230 -> 202 -> 164 -> 144 -> 123 -> 131 -> 123px against a settled
* 123px on owrt2512 at 767px — 107px of growth a floor never sees, on top of the shrink it does
* — each step landing between two of the poll's own separate section refreshes, so the browser
* paints in between and the reader is moved by exactly as much, on Chromium and Firefox as well
* as Safari (`tools/fit-quiet.mjs`, `../tmp/task-toplayout/pass-probe.mjs`). `min-height` alone
* was measured to `accc451`'s WebKit-only diagnosis instead — WebKit's own scroll anchoring
* looked like the whole story only because it is the one engine with no anchoring at all to hide
* this walk behind; Chromium and Firefox absorb it the same way they absorb any other layout
* change, which is not the same as not producing it.
*
* So both `min-height` AND `height` are pinned to the SAME value for the whole decision — a hard
* pin, not a floor — because nothing this pass measures (`stripFitsOneRow()`'s `offsetTop`,
* `clusterFitsBrandRow()`'s widths) reads the BAR's own height; `overflow: visible`
* (`theme/20-shell.css`) means a row the pin is too short for still lays out and measures
* correctly, it only paints past the pinned box's edge, which is invisible for the one
* synchronous pass before the pin comes off. The pin is released only once the final class set
* is decided — after `fitCluster()`, before `publishBarHeight()` — which must measure the bar
* the reader actually gets. */
const pinned = bar ? Math.round(bar.getBoundingClientRect().height) : 0;
const hadMinH = bar ? bar.style.minHeight : '';
const hadH = bar ? bar.style.height : '';
if (pinned > 0) { bar.style.minHeight = pinned + 'px'; bar.style.height = pinned + 'px'; }
if (bar) bar.classList.remove('fs-bar-stack', CLASS_IND_COMPACT, 'fs-bar-actrow');
fitTabStrips();
/* ---- does the main menu fit on the brand's row? ----
* It depends on how many sections THIS router has (stock 5, a loaded box 11), not on the
* viewport, so it is measured — a `max-width: 1199px` breakpoint stacked it on every laptop.
* Measured unstacked (the remove above), because a stacked menu owns a whole row, would "fit"
* and flip straight back.
*
* The menu's own pills wrapping is the "does not fit" signal, and only works because the
* unstacked top bar is flex-wrap: nowrap (50-toplayout.css) — otherwise the bar wraps, hands
* the menu a row and it always fits. Do not measure the bar's children by offsetTop instead:
* the bar is align-items:center over children of differing heights, so offsetTop differs even
* on one row. */
if (topBar && !stripFitsOneRow(menu)) {
/* first step before stacking: collapse the poll pill (~90px) to an icon square and
* re-measure — often enough to keep the menu on the brand's row
* (theme/50-toplayout.css) */
bar.classList.add(CLASS_IND_COMPACT);
fitTabStrips();
if (!stripFitsOneRow(menu)) {
bar.classList.add('fs-bar-stack');
fitTabStrips();
}
}
/* the cluster's own escalation, for every bar; after the menu's, so a menu that did not fit
* has already been given a row of its own by .fs-bar-stack */
if (bar && (topBar || document.documentElement.hasAttribute('data-narrow')))
fitCluster(bar, menu);
if (pinned > 0) { bar.style.minHeight = hadMinH; bar.style.height = hadH; }
publishBarHeight(bar);
}
/* ---- how tall the bar actually is, for whoever sticks underneath it ----
*
* A data table's header row sticks while its rows scroll past (theme/30-tables.css), and where the
* document scrolls it has to clear the sticky bar. `--fs-bar-h` is only the bar's DESIGNED height:
* it grows when the brand wraps, when the menu takes a row (.fs-bar-stack) or when the cluster does
* (.fs-bar-actrow), each decided by measurement above. So the measurement is published and the CSS
* falls back to the token.
*
* Written only on a change and rounded to the pixel: this runs on every fit pass, and a custom
* property write on :root invalidates style for the whole document. */
let _barH = 0;
function publishBarHeight(bar) {
const root = document.documentElement;
if (!bar) return;
const h = Math.round(bar.getBoundingClientRect().height);
if (!(h > 0) || h === _barH) return;
_barH = h;
root.style.setProperty('--fs-bar-live', `${h}px`);
}
/* ---- does the right-hand cluster still fit beside the brand? ----
*
* The cluster is four siblings (indicators, Search, Appearance, Log out), which flexbox wraps one
* at a time: on a narrow bar Log out alone drops to a second row, left-aligned under the hostname.
* Either the whole cluster shares the brand's row or it takes a row of its own, right-aligned.
*
* Two steps, cheapest first: collapse the pills to icon squares (~200px of prose), then give the
* cluster a row. .fs-ind-compact may already be set by the menu's escalation above and must not be
* cleared here — whoever asked for it still needs it. */
function fitCluster(bar, menu) {
bar.classList.remove('fs-bar-actrow');
if (clusterFitsBrandRow(bar, menu))
return;
bar.classList.add(CLASS_IND_COMPACT);
if (clusterFitsBrandRow(bar, menu))
return;
bar.classList.add('fs-bar-actrow');
}
/* Add the widths up rather than read positions, for the offsetTop reason above. The menu is
* excluded either way: on a row of its own (`ul.nav { flex: 1 1 100% }`) it is not competing, and
* where it shares the brand's row it is the child that shrinks (`flex: 1 1 auto`), so counting its
* current width would report the cluster as not fitting whenever the menu happened to be wide. */
function clusterFitsBrandRow(bar, menu) {
const cs = getComputedStyle(bar);
const gap = parseFloat(cs.columnGap) || 0;
const room = bar.clientWidth - parseFloat(cs.paddingLeft) - parseFloat(cs.paddingRight);
let need = -gap;
for (const el of bar.children) {
/* offsetParent is null for a display:none child, which is most of them in a bar */
if (el === menu || el.offsetParent === null)
continue;
need += el.offsetWidth + gap;
}
return need <= room;
}
/* No observer and no resize listener of our own — fs-fit owns both (docs/conventions.md). A view
* renders its .cbi-tabmenu into #view, which fs-fit's MutationObserver already watches and re-fits
* synchronously. #tabmenu is a sibling of #view, but nothing writes it except renderChrome(),
* which schedules a fit itself. */
/* modes -> #modemenu; drives the injected renderMainMenu for the active mode */
function renderModeMenu(node, renderMainMenu) {
const ul = document.querySelector('#modemenu');
const children = ui.menu.getChildren(node);
children.forEach((child, index) => {
const isActive = L.env.requestpath.length
? child.name === L.env.requestpath[0]
: index === 0;
/* the main menu must render even where a template has no #modemenu */
if (ul)
ul.appendChild(E('li', { 'class': isActive ? 'active' : '' }, [
E('a', { 'href': L.url(child.name) }, [ _(child.title) ])
]));
if (isActive)
renderMainMenu(child, child.name);
});
if (!ul)
return;
if (children.length <= 1)
ul.classList.add('single');
if (ul.children.length > 1)
ul.style.display = '';
}
/* rebuild mode menu + main menu + section tabs from the current L.env, on first load and after
* every SPA nav. Containers are cleared first so a re-render does not stack duplicates. */
function renderChrome() {
const root = tree.tree();
const modemenu = document.querySelector('#modemenu');
const topmenu = document.querySelector('#topmenu');
const tabmenu = document.querySelector('#tabmenu');
if (modemenu) { modemenu.innerHTML = ''; modemenu.style.display = 'none'; modemenu.classList.remove('single'); }
if (topmenu) topmenu.innerHTML = '';
if (tabmenu) { tabmenu.innerHTML = ''; tabmenu.style.display = 'none'; }
renderModeMenu(root, _renderMain);
if (L.env.dispatchpath.length >= 3) {
let node = root, url = '';
/* `node.children &&`, as fs-menutree's nodeForSegs() walks it: a childless node is an
* ordinary leaf, and reading `.children[…]` off one throws out of renderChrome(), taking
* the mode menu, the tabs and everything init wires after it */
for (let i = 0; i < 3 && node; i++) {
node = node.children && node.children[L.env.dispatchpath[i]];
url = url + (url ? '/' : '') + L.env.dispatchpath[i];
}
if (node)
renderTabMenu(node, url);
}
fit.schedule();
}
/* Sidebar rail toggle: collapse the sidebar to an icon-only strip. The state lives on
* <html data-rail> (head.ut re-applies it before paint) and in localStorage; everything else is
* CSS keyed off that attribute. */
function wireRail() {
const btn = document.getElementById('fs-rail-toggle');
if (!btn) return;
function sync() {
const on = prefs.currentRail();
btn.setAttribute('aria-expanded', on ? 'false' : 'true');
const label = on ? _('Expand menu') : _('Collapse menu');
btn.setAttribute('aria-label', label);
btn.setAttribute('title', label);
}
btn.addEventListener('click', () => {
prefs.applyRail(!prefs.currentRail());
sync();
/* the sidebar's cut just changed by ~156px, so the column may now clear or fall below
* --fs-content-min: re-measure rather than wait for a resize that is not coming */
fit.schedule();
});
sync();
}
/* An indicator pill carries its meaning as prose ("Unsaved Changes: 2") and the collapsed rail is
* 68px wide, so the pill wraps onto three lines and hangs past the rail's edge (issue #14). CSS
* squares the pill there and draws this attribute instead of the label — a text node cannot be
* reached by a selector, so the badge is lifted into an attribute here.
*
* The count is the only part that changes and the only part readable at that size. A pill with no
* trailing number falls back to a neutral dot; clipping the prose instead renders as garbage
* ("up pen"), because a centred pill gives an ellipsis no start to anchor to. The full prose
* stays in the label for screen readers, and in `title` for the pointer. */
const IND_DOT = '•';
/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM — same
* shape as `fsSyncAttr` in menu-footstrap-common.js, restated rather than imported (that file does
* not export it). */
function syncIndAttr(el, name, value) {
if (value === null) {
if (el.hasAttribute(name)) el.removeAttribute(name);
} else if (el.getAttribute(name) !== value) {
el.setAttribute(name, value);
}
}
/* A CLICKABLE `[data-indicator]` (the poll pill, "Unsaved Changes: N", …) ships as a bare
* span: no role, name or tabindex, so Tab skips it and a screen reader
* announces a run of text with no name, role or state (WCAG 2.1.1, 4.1.2). ui.showIndicator's own
* click handler already lives on this exact element — this only adds the second, W3C-APG way to
* reach it; it does not add a competing one. The name is the pill's own prose ("Refreshing"),
* never invented. Enter/Space call el.click() because a <span>, unlike an <a>, gets neither key
* for free (contrast fs-widgets.js's wireSpaceKey, written for an <a role="button">); calling
* click() cannot double-fire the mouse handler, since a keydown is not a click. */
function wireIndicatorKeyboard(el) {
if (el.dataset.fsWired) return;
el.dataset.fsWired = '1';
el.addEventListener('keydown', (ev) => {
if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return;
ev.preventDefault();
el.click();
});
}
function wireIndicatorCounts() {
const box = document.getElementById('indicators');
if (!box) return;
function stamp() {
box.querySelectorAll('[data-indicator]').forEach((el) => {
const txt = el.textContent || '';
const m = txt.match(/(\d+)\s*$/);
el.setAttribute('data-fs-badge', m ? m[1] : IND_DOT);
/* the rail hides the prose; the tooltip is where it stays reachable by pointer */
el.setAttribute('title', txt);
/* Only a CLICKABLE indicator becomes a control. `ui.showIndicator` sets
* `data-clickable` only when it was handed a handler, and a status-only pill is
* given none (luci-base ui.js; `battstatus.js` calls it with a null handler) — a
* `role="button"` tabstop with nothing behind it is worse than the bare span it
* replaces. The ring and the pointer cursor in theme/20-shell.css are scoped to
* `[data-clickable]` for the same reason; the two must not disagree. */
if (el.hasAttribute('data-clickable')) {
syncIndAttr(el, 'role', 'button');
syncIndAttr(el, 'tabindex', '0');
syncIndAttr(el, 'aria-label', txt.trim() || null);
wireIndicatorKeyboard(el);
}
});
}
/* ui.showIndicator replaces the label's text node on an update and appends the span on the
* first change of the session, so childList, subtree and characterData all matter. Our own
* attribute writes do not re-enter: attributes are not observed.
*
* AND THE BAR IS RE-FITTED HERE, because a pill arriving is a layout change the fit engine
* cannot see: fs-fit watches `#view` and the dialog, and `#indicators` is in the chrome. The
* cluster then wraps — flexbox answers first — and the compact form only follows when something
* else happens to wake the pass. Measured on WebKit at 390px in the narrow sidebar bar, a second
* indicator beside the poll pill pushed the whole page down 91px and held it there for 708 ms,
* until `fs-ind-compact` landed at 771 ms and it snapped back: a lurch down and up, once per
* appearance, which is what "the Overview twitches, as if an invisible loading bar came and
* went" is from the reader's side. Called from the same callback as `stamp()`, so the decision
* is taken in the microtask before paint and the wrapped frame is never drawn. It cannot
* re-enter: `fitChrome()` writes classes on the BAR and attributes here, and neither is what
* this observer watches.
*
* ON A PILL ARRIVING OR LEAVING, not on its text: `ui.showIndicator` rewrites the poll pill's
* label on every tick, and a fit is a handful of forced layouts — running one per tick to answer
* a label that did not change width is the cost this file spends its measurements avoiding. A
* childList record is the cluster gaining or losing a member, which is the layout change that
* wraps it. */
new MutationObserver((recs) => {
stamp();
if (recs.some((r) => r.type === 'childList' && r.target === box)) fitChrome();
})
.observe(box, { childList: true, subtree: true, characterData: true });
stamp();
}
return baseclass.extend({
setRenderMain,
renderChrome,
wireIndicatorCounts,
/* registered with fs-fit by the theme's init(): the bar's fit rides the same engine as the
* data tables' */
fitChrome,
/* The width a page's content column has: the sidebar or rail eats a known amount of the
* window and the shell adds a known padding, all memoised for fitShell(). Exported because a
* pass answering mid-scroll (fs-select's, for a table the poll just brought in) otherwise has
* only the window width, which in the sidebar layout is wrong by exactly the sidebar — at
* 800px the column is 520px, so a table judged to have room overflows.
*
* The arithmetic is columnWidth()'s; this only adds the page's current state. */
contentWidth() {
/* `_shellOuter` is refreshed only by measureShell(), which only fitShell() calls, and
* fitChrome() steps ASIDE FOR THE WHOLE SCROLL_IDLE WINDOW (400ms, fs-fit.js) whenever
* fit.scrolling() answers yes — including for a resize that lands mid-flick, since a
* resize is exactly what starts that window (fs-fit.js's resize observer feeds the same
* motion sampler `scrolling()` reads). A caller landing in that window, most of all
* fs-select's, got the width the PREVIOUS viewport had: at 568px settling to 390px, model
* stayed 568 for up to 220ms of the 400 (measured: 178px, exactly 568390;
* ../tmp/task-vnstat/probe2.mjs, probe3.mjs; live-audit's `geometry|fs-content` finding on
* owrt2410, CI run 34364446910).
*
* So the window's own width is compared fresh on every call, not only when `_shellOuter`
* is still zero. `clientWidth` is the one read the old "no layout read" promise here was
* already conditional on: the bootstrap branch (no fitter has run yet) made this exact
* call through measureShell(). A plain `!==` compares against the cached width for free —
* nothing invalidated layout since the last read, so this costs nothing when nothing
* moved — and measureShell()'s own further reads (the resolved gutter) only run when the
* comparison says the width actually did. */
if (document.documentElement.clientWidth !== _shellOuter) measureShell();
const root = document.documentElement;
return columnWidth(shellGeometry(), {
outerW: _shellOuter,
narrow: root.hasAttribute('data-narrow'),
top: prefs.isTopLayout(),
rail: prefs.currentRail()
});
},
/* exported for tests/chrome-geometry.test.mjs (no tests ship in the package): driving the
* arithmetic directly is the only way to cover every combination of layout, rail and width
* without a browser */
columnWidth, /* fs:probe */
wireRail
});