openvibers/openvibe.live Commits

Rolling numbers read as numbers: screen readers and copy get the value (#712)

OP OpenVibers committed 4 days ago · 112d02874d · 2 files, +60 −2

ov-odometer.js draws each digit as a strip of 0 to 9 that rolls, and the strips were plain text: a screen reader
read a counter of 1,234 as four runs of "0 1 2 3 4 5 6 7 8 9", and copying it pasted the same (found when the
overlap sweep flagged the hero's counters, 11-09). The value now sits in a visually hidden span that follows every
update and rebuild, and the strips and cells are aria-hidden.

Changes

ddiff --git a/public/js/ov-odometer.js b/public/js/ov-odometer.js
iindex c7539f2d..36382f54 100644
---- a/public/js/ov-odometer.js
++++ b/public/js/ov-odometer.js
@@@ -26,6 +26,7 @@
not the line box, and digits overflow it. */
.ovnum{--ovnum-h:1.25em;display:inline-flex;align-items:center;line-height:var(--ovnum-h);font-variant-numeric:tabular-nums}
.ovnum-cell{display:inline-block;line-height:var(--ovnum-h)}
++.ovnum-sr{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0}
.ovnum-digit{display:inline-block;overflow:hidden;height:var(--ovnum-h);width:1ch;position:relative}
.ovnum-track{display:block;transition:transform var(--ovnum-dur,.62s) cubic-bezier(.22,1,.28,1)}
.ovnum-still .ovnum-track{transition:none}
@@@ -61,17 +62,22 @@
// Rebuild only when the shape changes; otherwise reuse the cells so digits can roll.
if (el.dataset.shape !== chars.map(c => (/\d/.test(c) ? '#' : c)).join('')) {
el.textContent = '';
++ // The number itself, for screen readers, copy and search: the rolling strips hold every digit 0-9 and
++ // read as "0123456789" for each one (11-09), so they are hidden from them.
++ const sr = document.createElement('span'); sr.className = 'ovnum-sr'; el.appendChild(sr);
for (const ch of chars) {
if (/\d/.test(ch)) {
-- const d = document.createElement('span'); d.className = 'ovnum-digit';
++ const d = document.createElement('span'); d.className = 'ovnum-digit'; d.setAttribute('aria-hidden', 'true');
d.appendChild(digitTrack()); el.appendChild(d);
} else {
-- const c = document.createElement('span'); c.className = 'ovnum-cell'; c.textContent = ch; el.appendChild(c);
++ const c = document.createElement('span'); c.className = 'ovnum-cell'; c.setAttribute('aria-hidden', 'true'); c.textContent = ch; el.appendChild(c);
}
}
el.dataset.shape = chars.map(c => (/\d/.test(c) ? '#' : c)).join('');
animate = false; // a freshly built shape has nothing to roll from
}
++ const sr = el.querySelector(':scope > .ovnum-sr');
++ if (sr) sr.textContent = text;
const digits = el.querySelectorAll('.ovnum-digit');
let di = 0;
for (const ch of chars) {
ddiff --git a/test/odometer-a11y.test.js b/test/odometer-a11y.test.js
nnew file mode 100644
iindex 00000000..227f32bc
---- /dev/null
++++ b/test/odometer-a11y.test.js
@@@ -0,0 +1,52 @@
++'use strict';
++/**
++ * ov-odometer.js draws each digit as a strip of 0-9 that rolls. Those strips were plain text, so a screen reader read a
++ * counter of 1,234 as four runs of "0 1 2 3 4 5 6 7 8 9", and copying it pasted the same (11-09: found by the overlap
++ * sweep flagging the hero's counters). Now the number itself sits in a visually hidden span that follows every update,
++ * and the strips and cells are aria-hidden.
++ *
++ * node test/odometer-a11y.test.js
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const vm = require('vm');
++
++function loadLinkedom() {
++ try { return require('linkedom'); }
++ catch { return require(require.resolve('linkedom', { paths: [path.dirname(require.resolve('openvibe-shared/package.json'))] })); }
++}
++const { parseHTML } = loadLinkedom();
++const { window, document } = parseHTML('<!doctype html><html><head></head><body><b id="n"></b></body></html>');
++window.requestAnimationFrame = (fn) => setTimeout(fn, 0);
++const ctx = vm.createContext({ window, document, setTimeout, requestAnimationFrame: window.requestAnimationFrame, Number, String });
++vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'ov-odometer.js'), 'utf8'), ctx);
++
++/** The text a screen reader gets: every text node not inside an aria-hidden element. */
++function spoken(el) {
++ let out = '';
++ const walk = (n) => {
++ if (n.nodeType === 3) { out += n.textContent; return; }
++ if (n.nodeType === 1 && n.getAttribute('aria-hidden') === 'true') return;
++ for (const c of n.childNodes) walk(c);
++ };
++ walk(el);
++ return out;
++}
++
++const el = document.getElementById('n');
++window.OVNum.mount(el, 1234);
++assert.strictEqual(spoken(el), '1,234', 'a screen reader hears the number, not the strips');
++assert.strictEqual(el.querySelectorAll('.ovnum-digit[aria-hidden="true"]').length, 4, 'every rolling digit is hidden from it');
++assert.strictEqual(el.querySelectorAll('.ovnum-cell[aria-hidden="true"]').length, 1, 'and the comma cell');
++assert.strictEqual(el.querySelectorAll('.ovnum-sr').length, 1, 'one hidden copy of the value');
++
++window.OVNum.set(el, 1300); // same shape: the cells are reused and roll
++assert.strictEqual(spoken(el), '1,300', 'the hidden value follows a roll');
++window.OVNum.set(el, 98765); // a new shape: rebuilt
++assert.strictEqual(spoken(el), '98,765', 'and a rebuild');
++assert.strictEqual(el.querySelectorAll('.ovnum-sr').length, 1, 'still one copy after a rebuild');
++
++const src = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'ov-odometer.js'), 'utf8');
++assert.ok(/\.ovnum-sr\{position:absolute;width:1px;height:1px;[^}]*clip-path:inset\(50%\)/.test(src), 'the copy is visually hidden, not display:none');
++console.log('odometer: screen readers and copy get the number; the rolling strips are hidden from them');