openvibers/openvibe.shared Commits

Shared 4.2.1: a showcase table that scrolls is a focusable, named region (#80)

OP OpenVibers committed 29 days ago · b8ea97dbea · 5 files, +19 −5

A showcase table that scrolls is a focusable region. `compare()` and `limits()` wrap their table in `.sc-table-wrap`, which scrolls sideways on a phone, so the wrapper is now `tabindex="0" role="region"`, labelled by its section heading.

An axe scan (WCAG 2.1 AA) of every OpenVibe home at 390 px flagged it on ai.openvibe.services (`scrollable-region-focusable`, serious). Code samples already were focusable.

`test/showcase.test.js` checks both tables. The suite passes (59/60, floating-promises skipped locally). Version 4.2.1.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 75c2886..9d2d367 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -4,6 +4,13 @@ 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.2.1 — 2026-10-15
++
++- **A showcase table that scrolls is a focusable region.** `compare()` and `limits()` wrap their table in
++ `.sc-table-wrap`, which scrolls sideways on a phone. The wrapper is now `tabindex="0" role="region"`, labelled by the
++ section's heading. An axe scan (WCAG 2.1 AA) of every OpenVibe home at 390 px flagged it on ai.openvibe.services
++ (`scrollable-region-focusable`, serious). Code samples already were.
++
## 4.2.0 — 2026-10-15
- **"Ask Actor" in the account menu.** Signed in, the menu's You section has Ask Actor after Notifications. It opens
ddiff --git a/package-lock.json b/package-lock.json
iindex 08701e0..e58d378 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -1,12 +1,12 @@
{
"name": "openvibe-shared",
-- "version": "4.2.0",
++ "version": "4.2.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "openvibe-shared",
-- "version": "4.2.0",
++ "version": "4.2.1",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex 54f2597..7b0b7d6 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-shared",
-- "version": "4.2.0",
++ "version": "4.2.1",
"description": "Shared navbar, footer, themes, icons, legal pages, SEO, auth and analytics for every OpenVibe site",
"main": "index.js",
"bin": {
ddiff --git a/showcase.js b/showcase.js
iindex ade15b2..a5026f4 100644
---- a/showcase.js
++++ b/showcase.js
@@@ -88,11 +88,15 @@ function demo({ id: sid, title, lede, iframe, html } = {}) {
const cell = (v) => (v === true ? '<span class="sc-yes" aria-label="yes">✓</span>' : v === false || v == null ? '<span class="sc-no" aria-label="no">—</span>' : esc(v));
++// A table wider than a phone scrolls inside its wrapper, so the wrapper is a region the keyboard can reach and name
++// (axe scrollable-region-focusable, WCAG 2.1.1): focusable, and labelled by the section's heading.
++const tableWrap = (title, h) => `<div class="sc-table-wrap" tabindex="0" role="region" ${title ? `aria-labelledby="${h}"` : 'aria-label="Table"'}>`;
++
/** How the product compares, feature by feature. The first column is this product. */
function compare({ id: sid, title, lede, columns, rows } = {}) {
if (!has(rows) || !has(columns)) return '';
const h = `sc-${slug(title)}`;
-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-table-wrap"><table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;
++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${tableWrap(title, h)}<table class="sc-table"><thead><tr><th scope="col"><span class="sr-only">Feature</span></th>${columns.map((c, i) => `<th scope="col"${i === 0 ? ' class="sc-us"' : ''}>${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td${i === 0 ? ' class="sc-us"' : ''}>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div></section>`;
}
/** Plans side by side. A price of 0 reads "Free". */
@@@ -109,7 +113,7 @@ function pricing({ id: sid, title, lede, tiers, note } = {}) {
function limits({ id: sid, title, lede, columns, rows, source } = {}) {
if (!has(rows) || !has(columns)) return '';
const h = `sc-${slug(title)}`;
-- return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}<div class="sc-table-wrap"><table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;
++ return `<section class="sc-sec"${id(sid)}${labelled(title, h)}>${head(title, lede, h)}${tableWrap(title, h)}<table class="sc-table sc-limits"><thead><tr><th scope="col">Limit</th>${columns.map((c) => `<th scope="col">${esc(c)}</th>`).join('')}</tr></thead><tbody>${rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${columns.map((_, i) => `<td>${cell((r.values || [])[i])}</td>`).join('')}</tr>`).join('')}</tbody></table></div>${source ? `<p class="sc-note">These are the limits the service enforces, read from <a href="${esc(source)}">${esc(source.replace(/^https?:\/\//, ''))}</a>.</p>` : ''}</section>`;
}
/** What people do with it, in their words. Only real uses: the caller passes nothing it cannot stand behind. */
ddiff --git a/test/showcase.test.js b/test/showcase.test.js
iindex 3c2c74c..3943eb4 100644
---- a/test/showcase.test.js
++++ b/test/showcase.test.js
@@@ -26,6 +26,9 @@ const p = sc.pricing({ title: 'Plans', tiers: [{ name: 'Free', price: 0, per: 'm
assert.ok(p.includes('<p class="sc-price">Free</p>') && p.includes('$5<small> / month</small>') && p.includes('sc-tier sc-hl'));
const l = sc.limits({ title: 'Limits', columns: ['Free'], rows: [{ label: 'Requests per minute', values: [60] }], source: 'https://openvibe.network/limits.json' });
assert.ok(l.includes('<td>60</td>') && l.includes('href="https://openvibe.network/limits.json"'), 'limits cite their source');
++// A table wider than a phone scrolls in its wrapper: the wrapper is a focusable region named by the section's heading.
++assert.ok(c.includes('<div class="sc-table-wrap" tabindex="0" role="region" aria-labelledby="sc-compare">') && c.includes('id="sc-compare"'), 'compare table region');
++assert.ok(l.includes('<div class="sc-table-wrap" tabindex="0" role="region" aria-labelledby="sc-limits">'), 'limits table region');
const code = sc.code({ title: 'Code', samples: [{ label: 'Node', lang: 'js', code: '\n<script>x</script>\n' }] });
// A code sample scrolls sideways, so it is a focus stop with a name (axe scrollable-region-focusable).
assert.ok(code.includes('<pre tabindex="0" aria-label="Node">'), 'code samples are keyboard-focusable and named');