Files
builder/scripts/test-input-labels.mjs
Nariman Jelveh 9d86b89f3b Keep keyboard focus in place through sidebar pin, rename and open
Focus on a sidebar entry fell to <body> in several places, so the next
Tab started over from the top of the page and a screen reader lost its
place:
- Pinning or unpinning, a rename landing, or another project's AI title
  arriving mid-build: each rebuilds the list and replaces the focused
  link or ⋮.
- Finishing a rename with Enter or ✓, or cancelling it with Escape or ✕:
  the editor that held focus is swapped for the plain title.
- Opening a project from the keyboard: the sidebar closes around the
  focused entry.

Now:
- A rebuild hands focus to the same control on the rebuilt entry. If the
  entry is gone (deleted), focus goes to the entry now in its place, or
  to the search box when the list is empty.
- Finishing or cancelling a rename from the editor returns focus to the
  entry's ⋮.
- Opening a project from the keyboard puts focus in the composer once
  the project is in.

A tap or click moves nothing. A phone keyboard doesn't pop up, and a
mouse user's list doesn't scroll to a ⋮ they clicked earlier.
2026-10-04 15:57:52 -07:00

54 lines
2.4 KiB
JavaScript

import fs from 'node:fs';
// ---- Regression guard: every text field the UI builds has a name ------------
// A screen reader announces a field by its accessible name. Several of the
// UI's fields had none — the Publish popover's and Settings' site-address
// fields, the sidebar's rename field — so they were read as a bare "edit
// text", and the typed-word delete confirmation was named only by its
// placeholder, the word itself. Every <input>, <textarea> and <select> written
// in the UI's markup must carry aria-label / aria-labelledby, sit inside a
// <label>, or be a hidden file picker.
let failures = 0;
function check(name, cond, detail) {
if (cond) console.log('ok - ' + name);
else { console.error('FAIL - ' + name + (detail ? '\n ' + detail : '')); failures++; }
}
const roots = ['../src/js', '../src/tools'];
const files = [];
const walk = (dir) => {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const p = new URL(ent.name + (ent.isDirectory() ? '/' : ''), dir);
if (ent.isDirectory()) walk(p);
else if (/\.m?js$/.test(ent.name)) files.push(p);
}
};
for (const r of roots) walk(new URL(r + '/', import.meta.url));
let seen = 0;
for (const f of files) {
const src = fs.readFileSync(f, 'utf8');
const rel = f.pathname.split('/src/')[1];
src.split('\n').forEach((line, i) => {
for (const m of line.matchAll(/<(?:input|textarea|select)\b[^>]*>/g)) {
seen++;
const tag = m[0];
const named = /aria-label(ledby)?=/.test(tag)
|| /type="file"/.test(tag)
|| /<label\b[^>]*>(?:(?!<\/label>).)*$/.test(line.slice(0, m.index));
check(`${rel}:${i + 1} ${(tag.match(/class="([^"]+)"/) || tag.match(/name="([^"]+)"/) || [, tag.slice(0, 40)])[1]} has an accessible name`, named, tag);
}
});
}
check('the scan found the UI\'s inputs (sanity)', seen >= 10, String(seen));
{
const ui = fs.readFileSync(new URL('../src/js/ui.js', import.meta.url), 'utf8');
check('the delete confirmation\'s field is named by its "To confirm, type …" hint',
/class="confirm-modal-input" aria-labelledby="confirm-modal-hint"/.test(ui) && /class="confirm-modal-hint" id="confirm-modal-hint"/.test(ui));
}
if (failures) { console.error(`\n${failures} check(s) FAILED`); process.exit(1); }
console.log('\nAll input-label checks passed.');