diff --git a/filebrowser-q/files/filebrowser.init b/filebrowser-q/files/filebrowser.init index a91522a47..d0d26ac0c 100644 --- a/filebrowser-q/files/filebrowser.init +++ b/filebrowser-q/files/filebrowser.init @@ -16,7 +16,7 @@ start() { [ "$enabled" -eq "1" ] || return 1 local listen_port root_path root_name - config_get listen_port "config" "listen_port" "8989" + config_get listen_port "config" "listen_port" "8787" config_get root_path "config" "root_path" "/" # 路径为 / 时 name 设为 root,否则留空 @@ -43,9 +43,9 @@ userDefaults: EOF else grep -q " name:" "$CONF_PATH" || sed -i "s,.*- path:.*,&\n name: \"\"," "$CONF_PATH" - sed -e "s/.*port:.*/ port: $listen_port/" \ - -e "s,.*- path:.*, - path: \"$root_path\"," \ - -e "s/.*name:.*/ name: \"$root_name\"/" \ + sed -e "s/ port:.*/ port: $listen_port/" \ + -e "s, - path:.*, - path: \"$root_path\"," \ + -e "s/ name: \"root\"/ name: \"$root_name\"/" \ -i "$CONF_PATH" fi diff --git a/luci-app-filebrowser-q/htdocs/luci-static/resources/view/filebrowser-q.js b/luci-app-filebrowser-q/htdocs/luci-static/resources/view/filebrowser-q.js index aa126971a..0aec6c308 100644 --- a/luci-app-filebrowser-q/htdocs/luci-static/resources/view/filebrowser-q.js +++ b/luci-app-filebrowser-q/htdocs/luci-static/resources/view/filebrowser-q.js @@ -76,7 +76,7 @@ return view.extend({ o = s.option(form.Value, 'listen_port', _('Listen port')); o.datatype = 'port'; - o.default = '8989'; + o.default = '8787'; o.rmempty = false; o = s.option(form.Value, 'root_path', _('Root directory')); diff --git a/luci-theme-footstrap/Makefile b/luci-theme-footstrap/Makefile index d542c54e9..fa190a322 100644 --- a/luci-theme-footstrap/Makefile +++ b/luci-theme-footstrap/Makefile @@ -17,7 +17,7 @@ LUCI_NAME:=luci-theme-footstrap FOOTSTRAP_VERSION?= ifneq ($(FOOTSTRAP_VERSION),) PKG_VERSION:=$(FOOTSTRAP_VERSION) -PKG_RELEASE:=60 +PKG_RELEASE:=61 endif LUCI_TITLE:=Footstrap Theme diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js index e5986b113..ca2efff1c 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-chrome.js @@ -480,6 +480,35 @@ function wireRail() { * 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 , unlike an , gets neither key + * for free (contrast fs-widgets.js's wireSpaceKey, written for an ); 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; @@ -491,6 +520,18 @@ function wireIndicatorCounts() { 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); + } }); } diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js index 3f920d4a3..50ed59d41 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-overview.js @@ -3,6 +3,7 @@ 'require dom'; 'require network'; 'require fs-fit as fit'; +'require menu-footstrap-common as common'; /* Overview layout only: renders nothing of its own, it re-arranges the STOCK System / Memory / * Storage sections into a grid. Content, data and styling stay luci-mod-status's — rendering a @@ -23,11 +24,15 @@ const ROLES = { [_('System')]: 'sys', [_('Memory')]: 'mem', [_('Storage')]: 'sto /* the data-page value four call sites compare against; a string literal is not mangled, so a * repeat is paid in full on flash every time (measured: 24 B x4 -> 37 B, 59 B saved) */ -function sectionTitle(sec) { +function headerEl(sec) { /* two title markups, one per release: 25.12 wraps the heading (`.cbi-title > h3`), 24.10 emits * a bare `

` as the section's first child. Matching only one silently disables the grid on * the other. */ - const h = sec.querySelector('.cbi-title h3, :scope > h3'); + return sec.querySelector('.cbi-title h3, :scope > h3'); +} + +function sectionTitle(sec) { + const h = headerEl(sec); if (!h) return ''; /* the first non-empty TEXT node, not `firstChild`: 25.12 appends a hide/show inside the * same

, so `firstChild` depends on upstream keeping the words first */ @@ -39,6 +44,101 @@ function sectionTitle(sec) { return ''; } +/* ---- keyboard disclosure: the card header becomes the toggle, the pill becomes its glyph ---- + * + * Live on owrt2512 (25.12.4), Status -> Overview carries 14 `[data-clickable]` elements — the + * topbar poll pill plus one Hide/Show toggle per card — and every one is a bare : no + * tabindex, no role, no aria-expanded. Tab never reaches one and a screen reader announces a run + * of text with no name, role or state. WCAG 2.1.1 Keyboard (A), 4.1.2 Name, Role, Value (A). + * docs/findings.md, "A card cannot be collapsed from the keyboard". + * + * index.js's own pill keeps doing the actual show/hide — untouched, so mouse behaviour for anyone + * clicking it does not change. The header becomes a second, W3C-APG way to reach the SAME handler, + * not a competing one. */ + +/* Idempotent attribute write, so a poll tick that finds nothing changed touches no DOM and fires + * no mutation record. Same shape as `fsSyncAttr` in menu-footstrap-common.js — restated rather than + * imported, since that file does not export it. */ +function syncAttr(el, name, value) { + if (value === null) { + if (el.hasAttribute(name)) el.removeAttribute(name); + } else if (el.getAttribute(name) !== value) { + el.setAttribute(name, value); + } +} + +/* index.js's own attribute: "inactive" is expanded (the pill reads "Hide"), "active" is collapsed + * (it reads "Show") — the chevron mirror in pages/20-overview.css reads the same attribute the + * same way. */ +function pillExpanded(label) { + return label.getAttribute('data-style') !== 'active'; +} + +/* The panel `data-style` shows or hides is `.cbi-title`'s next sibling in the 25.12 markup this was + * measured on. 24.10 emits no `.cbi-title` wrapper (headerEl() above) and is left without + * aria-controls rather than guessed at — role, tabindex, aria-expanded and the keyboard below still + * apply there; aria-controls is the one piece this function cannot state with confidence. */ +function panelFor(h) { + const holder = h.closest('.cbi-title'); + return holder ? holder.nextElementSibling : null; +} + +let _panelSeq = 0; + +/* Promote one card's header to a disclosure control. Idempotent: a tick that finds the header + * already wired touches only aria-expanded, and only when it actually changed; the click/keydown + * listeners are added once (`dataset.fsWired`), never re-added. */ +function wireDisclosure(sec) { + const h = headerEl(sec); + const label = h && h.querySelector('.label[data-indicator="poll-status"]'); + if (!h || !label) return; /* not this card's shape — left alone rather than guessed at */ + + const panel = panelFor(h); + if (panel && !panel.id) panel.id = 'fs-ovl-panel-' + (_panelSeq++); + + syncAttr(h, 'role', 'button'); + syncAttr(h, 'tabindex', '0'); + syncAttr(h, 'aria-controls', panel ? panel.id : null); + syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false'); + /* the header now carries the pill's name, role and state; a screen reader user tabbing past it + * to a second, unlabelled clickable span would hear an unexplained duplicate control — same + * reasoning as the aria-hidden on svgIcon()'s output, fs-widgets.js:12 */ + syncAttr(label, 'aria-hidden', 'true'); + + if (h.dataset.fsWired) return; + h.dataset.fsWired = '1'; + + h.addEventListener('click', (ev) => { + /* a click landing on the pill itself already ran index.js's own handler; forwarding here + * too would toggle the card twice */ + if (ev.target.closest?.('[data-indicator="poll-status"]') !== label) label.click(); + syncAttr(h, 'aria-expanded', pillExpanded(label) ? 'true' : 'false'); + }); + /* the pill is an -less , so neither key is native here — contrast + * fs-widgets.js's wireSpaceKey, written for an , which gets Enter for free + * and needs only Space added */ + h.addEventListener('keydown', (ev) => { + if (ev.key !== 'Enter' && ev.key !== ' ' && ev.key !== 'Spacebar') return; + ev.preventDefault(); + label.click(); + }); +} + +/* A `.cbi-section` LuCI still renders when a stock include has nothing to show this tick: title + * "-", the poll pill its only content, 72px tall on the live Overview — seventh there (a fixture is + * not proof of position: docs/playground.html puts it first). Real and cosmetic, so it is + * suppressed rather than left as a rung in the tab order with nothing behind its own name. */ +function hideEmptyCard(sec) { + if (sectionTitle(sec) === '-') sec.classList.add('fs-ovl-empty'); +} + +function tidyCards(view) { + view.querySelectorAll('.cbi-section').forEach((sec) => { + hideEmptyCard(sec); + wireDisclosure(sec); + }); +} + /* the wrapper we built, so the poll-tick fast path costs one property read */ let _wrapEl = null; @@ -75,6 +175,18 @@ function arrange() { if (!view) return; nameTooltips(view); + /* 20_memory.js, 25_storage.js and 30_network.js each call their OWN local progressbar(), not + * the theme's window.progressbar (menu-footstrap-common.js), so the reading and colour those + * bars need are stamped on here instead — on the same poll-tick callback nameTooltips() already + * runs on, ahead of the fast-path return below: dom.content() rewrites a section's title every + * tick even when the wrapper survives, so a bar's reading is stale on every tick this line is + * skipped. */ + common.annotateMeters(view); + /* Same reasoning, same placement: dom.content() rewrites a card's body every tick even when the + * wrapper survives, so a card that is only now getting its title text needs wiring on this + * pass, not just the first one. tidyCards() is its own idempotent write (wireDisclosure(), + * hideEmptyCard() above), so a tick that changes nothing here touches no DOM either. */ + tidyCards(view); /* Fast path: the poll lands here on every tick, forever, and the stock poll never rebuilds * the .cbi-section wrappers, so the grid survives. Deliberately not a disconnect() — if a @@ -276,7 +388,10 @@ function patchOverview() { /* `progressbar`, `renderBox` and `renderBadge` are defined in menu-footstrap-common.js, not here: * a stock include calls them bare from its own render(), so they must exist before the view class - * does, and this page module is required during the navigation that races it. */ + * does, and this page module is required during the navigation that races it. `annotateMeters` is + * required from that same module (see arrange()) rather than duplicated: it is not a template + * global, so nothing forces it to live there, but the threshold split and label lookup it shares + * with `window.progressbar` would drift into two copies otherwise. */ return baseclass.extend({ /* called once by menu-footstrap-common's init; everything route-dependent hangs off the diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js b/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js index 5f4d0f9fd..3b6a6822e 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/fs-select.js @@ -316,6 +316,66 @@ function labelCells(t, head) { } } +/* ---- WCAG 1.3.1: a `display` override must not cost a table its own roles ---- + * + * theme/*.css restyles `display` on `.table`/`.thead`/`.tbody`/`.tfoot`/`.tr`/`.th`/`.td` in over + * twenty places — `.fs-stacked`, `.fs-rowstack`, the config table's own `@container`, the phone + * `@media`, the Overview meter row, the software page's carded description column, the 560px + * key/value card added alongside this file — scattered across selectors this module cannot see the + * live state of (a media query, a container query, a class some other pass toggled). A `display` + * other than `table`/`table-row`/`table-cell`/… drops the IMPLICIT `table`/`row`/`cell` role + * HTML-AAM derives from it: measured on Overview's key/value cards, `display: block`/`grid`/`block` + * (table/row/cell) at both 1440 and 390px, and on the 390px card stack, `block`/`none`/`block` — + * neither carries a role, so the column a value belongs to is never announced. + * + * Rather than pair each of those twenty-odd rules with a role write timed to its own trigger — a + * race with every one of them — the role is written UNCONDITIONALLY, matching what a plain + * `` already carries implicitly. That costs nothing while the table IS a table and survives + * the moment any of those rules land. tools/table-contract.mjs holds the pairing: a future + * `display` rule on one of these classes with no role to match is what it is written to catch. + * + * The whole chain, every pass — a `role="cell"` with nothing above it saying `row`, or a `row` with + * nothing above IT saying `table`/`rowgroup`, reads worse than no roles at all. One flat query per + * class keeps it whole without walking each table by hand: `.tr`/`.th`/`.td` are LuCI's own names, + * always written together with `.table` (adoptMarkup() above never adds one without the others), so + * a class query alone reaches the whole subtree. `.thead`/`.tbody`/`.tfoot` are the same for a + * div-based table; a REAL ``/``/`` this file never classes (20_lan.js's and + * 30_wifi.js's ``, theme/30-tables.css:570/634) is reached by tag name instead. */ +const TABLE_ROLE_CLASSES = [ + [ '.table', 'table' ], + [ '.thead', 'rowgroup' ], + [ '.tbody', 'rowgroup' ], + [ '.tfoot', 'rowgroup' ], + [ '.tr', 'row' ], + [ '.th', 'columnheader' ], + [ '.td', 'cell' ], +]; +const TABLE_ROLE_TAGS = [ + [ 'thead', 'rowgroup' ], + [ 'tbody', 'rowgroup' ], + [ 'tfoot', 'rowgroup' ], +]; + +/* idempotent, the same idiom as menu-footstrap-common.js's fsSyncAttr: a node that already carries + * the right role is a no-op read, so a poll tick re-rendering the same table on every tick touches + * no DOM and wakes no attribute observer. Not imported for one line — this file has none of that + * module's other exports to justify the dependency. */ +function setRole(el, role) { + if (el.getAttribute('role') !== role) el.setAttribute('role', role); +} + +/* A hidden row/cell (`.tr.cbi-section-table-descr`, `.td.hide-xs`/`.hide-sm` under `.fs-stacked`/ + * `.fs-drop-xs`) drops out of the accessibility tree on `display: none` alone — the role written + * here changes nothing about that, since a role is inert on a box the engine never generates. */ +function roleTables() { + TABLE_ROLE_CLASSES.forEach(([ sel, role ]) => { + document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role)); + }); + TABLE_ROLE_TAGS.forEach(([ sel, role ]) => { + document.querySelectorAll(inRoots(sel, liveRoots())).forEach((el) => setRole(el, role)); + }); +} + /* ---- card-stack a data table that no longer fits ---- * * Measuring, scheduling and the observers are fs-fit.js; this file supplies only the decision. @@ -890,12 +950,14 @@ return baseclass.extend({ 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. + * brings a fresh one back, so tagging leads; roleTables() follows it directly so a class + * adoptMarkup() just added carries its role in the same pass rather than waiting a tick. * - * Five registrations, not one callback: fs-fit catches per registered fitter, and the first + * Six 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(roleTables); fit.add(fitTables); fit.add(fitScrollables); fit.add(unpinActionColumn); diff --git a/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js b/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js index 43ba80a87..571419bfc 100644 --- a/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js +++ b/luci-theme-footstrap/htdocs/luci-static/resources/menu-footstrap-common.js @@ -153,6 +153,88 @@ function loadPlugins() { }); } +/* warn/danger split for the meter fill (theme/25-progressbar.css): a DECISION, not a measurement — + * 20% of an 84 MiB overlay and 20% of an 8 GiB disk are different news at the same reading, and a + * plain percentage is kept anyway so every meter on the page reads the same way. */ +const FS_METER_WARN = 80; +const FS_METER_DANGER = 92; + +/* Write an attribute only when the value actually changes, so a poll tick that reads the same + * numbers back touches no DOM and fires no attribute-mutation observer. `value === null` removes + * the attribute instead of writing the string "null". */ +function fsSyncAttr(el, name, value) { + if (value === null) { + if (el.hasAttribute(name)) el.removeAttribute(name); + } else if (el.getAttribute(name) !== value) { + el.setAttribute(name, value); + } +} + +/* The meter's name, if the markup already states one — never invented. A `.cbi-value` row's own + * label (the RSSI/RSRP gallery shape) or a key/value table row's first cell (Memory, Storage, CPU + * load on Overview) each stand for the whole row; a bare meter with neither (Software's disk-space + * bar, the package manager) gets no `aria-label` written at all, and Chromium's accessible-name + * computation then falls back to `title` — which is the reading, not a name ("95 / 100 (95%)"), + * measured with `Accessibility.getPartialAXTree`. That fallback cannot be closed from here: `title` + * is the ONLY source `::after { content: attr(title) }` has for the visible percentage + * (styles/theme/25-progressbar.css), so removing it would blank the bar for a sighted reader too. */ +function findProgressbarLabel(pg) { + const row = pg.closest('.cbi-value'); + let label = row ? row.querySelector('.cbi-value-title') : null; + if (!label) { + const tr = pg.closest('.tr'); + if (tr) { + const own = pg.closest('.td, td'); + const cell = tr.querySelector('.td, td'); + if (cell && cell !== own) label = cell; + } + } + return label; +} + +/* The percentage inside a meter's `title`, in either shape a caller writes it in: `window.progressbar` + * below composes `'%s / %s (%d%%)'`, parenthesised and preceded by the byte/localised reading, but a + * bare meter markup may carry just `'97%'` with nothing around it (docs/gallery.html). Both are + * anchored at the end of the string so neither can match a stray "%" earlier in a localised reading; + * an empty title or one with no percentage at all yields null, on purpose — nothing to annotate. + * `%d` is the unclamped percentage, so the result can still read past 100 or under 0 and is clamped + * by the caller, the same way `window.progressbar` clamps its own `level`, below. Exported for + * tests/meter.test.mjs, which is the only caller that needs the parse on its own. */ +function parseMeterPercent(title) { + if (title == null) return null; + const m = (/\((-?\d+)%\)\s*$/).exec(title) || (/(-?\d+)%\s*$/).exec(title); + return m ? parseInt(m[1], 10) : null; +} + +/* The attribute work `window.progressbar` and a bare `.cbi-progressbar` node both need — Status -> + * Overview's own 20_memory.js, 25_storage.js and 30_network.js (luci-mod-status) declare and call + * their OWN local `progressbar()`, a straight copy of the same upstream body, so this theme's + * global never runs on the meters those stock includes actually draw. `annotateMeters()` below + * walks the DOM after the fact instead of owning every writer; the threshold split and the name + * logic stay in this one function either way, so the two callers cannot drift apart. */ +function annotateMeter(pg) { + const title = pg.getAttribute('title'); + const pc = parseMeterPercent(title); + if (pc == null) return; + const level = pc < 0 ? 0 : (pc > 100 ? 100 : pc); + fsSyncAttr(pg, 'role', 'progressbar'); + fsSyncAttr(pg, 'aria-valuemin', '0'); + fsSyncAttr(pg, 'aria-valuemax', '100'); + fsSyncAttr(pg, 'aria-valuenow', String(level)); + fsSyncAttr(pg, 'aria-valuetext', title); + const label = findProgressbarLabel(pg); + fsSyncAttr(pg, 'aria-label', label ? label.textContent.trim() : null); + fsSyncAttr(pg, 'data-fs-level', + level >= FS_METER_DANGER ? 'danger' : (level >= FS_METER_WARN ? 'warn' : null)); +} + +/* Every `.cbi-progressbar[title]` under `root` — the markup itself, not who last drew it. Called + * from fs-overview.js's own poll-tick observer (see there for why this file does not run a second + * one); `fsSyncAttr` above makes a re-run over an unchanged bar a no-op read. */ +function annotateMeters(root) { + (root || document).querySelectorAll('.cbi-progressbar[title]').forEach(annotateMeter); +} + /* The three template globals Status -> Overview needs, defined where ordering is guaranteed. * * `admin_status/index.ut` defines `progressbar`, `renderBox` and `renderBadge` in an inline script @@ -165,29 +247,45 @@ function loadPlugins() { * ordering the two. This file is required by the footer on every page and evaluates before the * router exists. * - * Bodies are verbatim from upstream except for two deltas: L.itemlist -> window.L.itemlist (the - * two-L trap, docs/spa-router.md), and renderBox's `[title]` — dom.append parses a scalar child as + * Bodies are verbatim from upstream except for three deltas: L.itemlist -> window.L.itemlist (the + * two-L trap, docs/spa-router.md); renderBox's `[title]` — dom.append parses a scalar child as * innerHTML (luci.js:1395) and an array member as text (:1383), and this file defines the global on - * every admin page where upstream defines it on Status -> Overview alone. Same output: nothing in - * 24.10, 25.12 or master calls renderBox. The typeof guards make each a no-op on a full page load, - * where the template's own copies win the race. */ + * every admin page where upstream defines it on Status -> Overview alone; and progressbar's + * accessibility attributes (role, aria-value*, data-fs-level) — upstream's copy writes the reading + * into `title` alone, which nothing reads back for a screen reader off a generated `::after`. Same + * output on the other two: nothing in 24.10, 25.12 or master calls renderBox, and progressbar's + * `title` and fill width are untouched. + * + * `renderBox`/`renderBadge` keep the `typeof` guard: a full page load runs the template's inline + * `