openvibers/openvibe.shared Commits

4.95.0: a box that scrolls can be scrolled from a keyboard (#254)

OP OpenVibers committed 11 days ago · d748096b83 · 6 files, +123 −4

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex e640242..760efee 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,16 @@ All notable changes to `openvibe-shared`. Versions follow [semver](https://semve
breaking change to any exported module, browser global or served file name is a new major.
A release is the git tag `vX.Y.Z`; consumers pin the tag's tarball (see README).
++## 4.95.0 — 2026-11-02
++
++- **A box that scrolls can be scrolled from a keyboard** (WCAG 2.1.1). A code block, a wide table or a list held to a
++ height, with nothing focusable inside it, could not be reached with Tab, so its hidden part was out of reach without
++ a mouse. axe (scrollable-region-focusable) flagged such pages on six sites in the 11-02 sweep. Once the page settles,
++ and again a little later and after a resize, the navbar gives each such box `tabindex="0"` (and
++ `data-ov-scroll-focus`, with a focus ring). A box with a link or a button inside, a page's own tabindex, and
++ `overflow: hidden` are left alone. `OpenVibeNavbar.focusScrollers()` runs it on demand for a page that draws one
++ later. `test/keyboard-scrollers.test.js` checks it in a real Chrome.
++
## 4.94.0 — 2026-11-02
- **A labelled thing's poster may be named by username** (`data-ov-labels-owner="@name"`). The owner rule (their own
ddiff --git a/README.md b/README.md
iindex 7cb733c..5cb215c 100644
---- a/README.md
++++ b/README.md
@@@ -2,7 +2,7 @@
> Versioned UI, the OpenVibe Frame (the navbar, footer and "shipped" views every site sits in), SEO, legal and release-client packages every OpenVibe site renders.
--**Status:** alpha, v4.94.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
++**Status:** alpha, v4.95.0 (every release is a tag; see [CHANGELOG.md](CHANGELOG.md)). Every deployed
consumer installs a tagged release and none keeps a vendored copy
([docs/migration-plan.md](docs/migration-plan.md) is done); each repository's `package.json` names
the tag it pins.
ddiff --git a/STATUS.json b/STATUS.json
iindex e7bef34..287dc54 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -4,7 +4,7 @@
"code": true,
"wave": "W2",
"package": "openvibe-shared",
-- "version": "4.94.0",
++ "version": "4.95.0",
"plan": "OpenVibe End-to-End Realignment & Implementation Plan, revision 3 (20 Sep 2026)",
"deployed": false,
"deployment": "none of its own: a package released as git tags; OpenVibe.Network serves /shared/* from its pinned tag, and a site can serve its own pinned copy (openvibe-shared/serve)",
ddiff --git a/navbar.js b/navbar.js
iindex 65ba114..0b625af 100644
---- a/navbar.js
++++ b/navbar.js
@@@ -54,6 +54,7 @@
:where(.openvibe-navbar-brand) button.flame { all: unset; cursor: pointer; color: inherit; font: inherit; }
.openvibe-navbar .openvibe-navbar-brand .flame[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.18)); }
.openvibe-navbar .openvibe-navbar-brand button.flame:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
++ [data-ov-scroll-focus]:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; }
.openvibe-navbar .openvibe-navbar-brand { gap: 6px; padding: 2px; border-radius: 12px; margin-right: 0; }
.openvibe-navbar .openvibe-navbar-brand .flame { display: inline-grid; place-items: center; width: auto; min-width: 30px; height: 30px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; transition: background .15s; }
.openvibe-navbar .openvibe-navbar-brand .flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
@@@ -543,6 +544,43 @@
});
labelWatch.observe(document.body, { childList: true, subtree: true });
}
++ // A box that scrolls (a code block, a wide table, a list held to a height) with nothing focusable inside it cannot be
++ // scrolled from a keyboard (WCAG 2.1.1; axe scrollable-region-focusable flagged pages on six sites, 2026-11-02). Each
++ // such box gets tabindex="0", so Tab reaches it and the arrow keys scroll it. A box with a link or a button inside is
++ // reachable already and is left alone; a page's own tabindex always wins. Run when the page settles, again a little
++ // later for content that loads after it, and after a resize changes what overflows.
++ const SCROLL_FOCUSABLE = 'a[href],button,input,select,textarea,summary,iframe,[tabindex],[contenteditable="true"]';
++ function focusScrollers() {
++ if (!document.body || !document.body.getElementsByTagName) return 0;
++ let n = 0;
++ const all = document.body.getElementsByTagName('*');
++ for (let i = 0; i < all.length; i++) {
++ const el = all[i];
++ // Cheap first: only a box whose content is bigger than it is, and that has no tabindex of its own.
++ if (el.scrollWidth <= el.clientWidth + 1 && el.scrollHeight <= el.clientHeight + 1) continue;
++ if (el.hasAttribute('tabindex') || el.closest('#openvibe-navbar, .openvibe-navbar, .ovf, footer')) continue;
++ const cs = getComputedStyle(el);
++ const sx = /(auto|scroll)/.test(cs.overflowX) && el.scrollWidth > el.clientWidth + 1;
++ const sy = /(auto|scroll)/.test(cs.overflowY) && el.scrollHeight > el.clientHeight + 1;
++ if (!sx && !sy) continue;
++ if (el.querySelector(SCROLL_FOCUSABLE)) continue;
++ el.setAttribute('tabindex', '0');
++ el.setAttribute('data-ov-scroll-focus', '');
++ n++;
++ }
++ return n;
++ }
++ let scrollWatch = false;
++ function watchScrollers() {
++ if (scrollWatch || typeof window === 'undefined' || !document.body) return;
++ scrollWatch = true;
++ const idle = (fn, t) => (typeof root.requestIdleCallback === 'function' ? root.requestIdleCallback(fn, { timeout: t }) : setTimeout(fn, Math.min(t, 1500)));
++ const run = () => { try { if (typeof getComputedStyle === 'function') focusScrollers(); } catch { /* never a page's problem */ } };
++ idle(run, 3000);
++ setTimeout(() => idle(run, 2000), 4000);
++ let soon = 0;
++ if (typeof window.addEventListener === 'function') window.addEventListener('resize', () => { clearTimeout(soon); soon = setTimeout(run, 400); }, { passive: true });
++ }
function sibling(name) {
const m = /^(https?:\/\/[^?#]*\/)navbar\.js(?:[?#]|$)/.exec(_selfSrc);
return `${m ? m[1] : 'https://openvibe.network/shared/'}${name}`;
@@@ -1716,7 +1754,7 @@
}
// The network's own icons in place of Font Awesome's (ov-glyphs.js, L32) on a page that uses Font Awesome: now,
// or the first time the page's own script adds one (L54 K4: a page with no icon at first pays nothing).
-- if (_config.glyphs !== false) watchGlyphs(); watchLabels();
++ if (_config.glyphs !== false) watchGlyphs(); watchLabels(); watchScrollers();
nav.innerHTML = `
${brandHTML(brand)}
<div class="openvibe-navbar-links">
@@@ -2209,6 +2247,10 @@
/** Who is signed in and the token for Network (read only): for the files loaded beside the navbar (ov-labels.js). */
get session() { return { user: _config.user || null, token: _config.token || null, apiBase: _config.apiBase }; },
++ /** Give every box that scrolls with nothing focusable inside it tabindex="0" now (the bar does it when the page
++ * settles; a page that draws such a box later may call this). Returns how many it marked. */
++ focusScrollers() { try { return focusScrollers(); } catch { return 0; } },
++
/** Open the Dock (your agents and activity), on a tab: 'agent' | 'activity'. Signed-in people only. */
openDock(tab) { if (_config.token && _config.dock !== false) openDock(tab); },
ddiff --git a/package.json b/package.json
iindex e2c8804..93ea5f2 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.94.0",
++ "version": "4.95.0",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/test/keyboard-scrollers.test.js b/test/keyboard-scrollers.test.js
nnew file mode 100644
iindex 0000000..d5ab1cb
---- /dev/null
++++ b/test/keyboard-scrollers.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++// Every box that scrolls with nothing focusable inside it can be scrolled from a keyboard (WCAG 2.1.1; axe
++// scrollable-region-focusable, flagged on six sites' pages on 2026-11-02). The navbar gives each such box tabindex="0"
++// once the page settles, in a real headless Chrome (layout decides what overflows). Skipped (exit 0) without Chrome or
++// with OV_SKIP_BROWSER=1.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const h = require('../browser-harness');
++
++if (process.env.OV_SKIP_BROWSER === '1' || !h.findChrome()) {
++ console.log('keyboard-scrollers: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++const ROOT = path.join(__dirname, '..');
++const long = 'const x = 1; '.repeat(80);
++const PAGE = `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Scrollers</title><link rel="icon" href="data:,">
++<script src="/shared/navbar.js" defer></script>
++<style>body{margin:0;font:16px system-ui} .box{width:300px;overflow:auto} .tall{height:60px;overflow-y:auto} .clip{width:300px;overflow:hidden}</style></head>
++<body><div id="navbar-mount"></div><main>
++<pre id="wide" class="box">${long}</pre>
++<pre id="short" class="box">ok</pre>
++<div id="with-link" class="box"><p style="width:900px">A wide row <a href="/x">with a link</a></p></div>
++<dl id="tall" class="tall"><dt>a</dt><dd>1</dd><dt>b</dt><dd>2</dd><dt>c</dt><dd>3</dd><dt>d</dd><dd>4</dd></dl>
++<pre id="own" class="box" tabindex="-1">${long}</pre>
++<div id="clipped" class="clip"><p style="width:900px">Clipped, not scrollable</p></div>
++</main>
++<script>document.addEventListener('DOMContentLoaded', function () { OpenVibeNavbar.init({ service: 'site', user: null, links: [{ label: 'Home', href: '/' }] }); });</script>
++</body></html>`;
++const server = http.createServer((req, res) => {
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/') { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }); return res.end(PAGE); }
++ const m = /^\/shared\/([a-z0-9-]+\.js)$/.exec(u.pathname);
++ if (m && fs.existsSync(path.join(ROOT, m[1]))) { res.writeHead(200, { 'content-type': 'application/javascript' }); return res.end(fs.readFileSync(path.join(ROOT, m[1]))); }
++ res.writeHead(404); return res.end();
++});
++
++(async () => {
++ await new Promise((ok) => server.listen(0, '127.0.0.1', ok));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-scrollers-'));
++ const browser = await h.launch({ tmpDir });
++ try {
++ const context = await browser.newContext();
++ const p = await h.openPage(browser, context, { width: 1024, block: ['*openvibe.network*', '*openvibe.tools*', '*jsdelivr*'] });
++ await p.goto(`${base}/`, { maxMs: 5000 });
++ // The bar runs it when the page settles (idle, at most 3 s); wait for the first mark, or 6 s.
++ await p.evaluate(`new Promise((r) => { const t0 = Date.now(); const t = setInterval(() => { if (document.querySelector('[data-ov-scroll-focus]') || Date.now() - t0 > 6000) { clearInterval(t); r(); } }, 100); })`);
++ const state = await p.evaluate(`JSON.stringify(Object.fromEntries(['wide','short','with-link','tall','own','clipped'].map((id) => { const e = document.getElementById(id); return [id, e.getAttribute('tabindex')]; })))`);
++ const s = JSON.parse(state);
++ assert.strictEqual(s.wide, '0', 'a wide code block a keyboard could not reach gets tabindex 0');
++ assert.strictEqual(s.tall, '0', 'so does a list held to a height');
++ assert.strictEqual(s.short, null, 'a box that does not overflow is left alone');
++ assert.strictEqual(s['with-link'], null, 'a box with a link inside is reachable already');
++ assert.strictEqual(s.own, '-1', 'a page\'s own tabindex wins');
++ assert.strictEqual(s.clipped, null, 'overflow hidden is not a scroller');
++ assert.strictEqual(await p.evaluate('typeof OpenVibeNavbar.focusScrollers'), 'function');
++ assert.strictEqual(await p.evaluate('OpenVibeNavbar.focusScrollers()'), 0, 'running it again changes nothing');
++ console.log('keyboard-scrollers: all passed');
++ } finally {
++ await browser.close();
++ server.close();
++ }
++})().catch((e) => { console.error(e); process.exit(1); });