feat(voyage): implement J/K keyboard navigation + Esc + aria-live announces

Step 20 of v4.3 playground plan. Document-level keydown handler:
  - J = next annotation (next sorted-by-line draft, wraps)
  - K = prev annotation (wraps)
  - ] = toggle sidebar visibility
  - Escape = clear active anchor + sidebar list selection

Active annotation gets yellow-tint (Step 18 setActiveAnchor) and the
matching gutter-badge receives focus + scrollIntoView. Aria-live region
announces position + target: "Annotering 3 av 7: <target> — <snippet>".

Skips input/textarea/select/contenteditable so playground never steals
keystrokes from form fields. Modifier keys (Ctrl/Alt/Meta) pass through
to browser shortcuts. Wired in init() after dashboard nav.

Trace: SC2 (WCAG AA keyboard), SC6, research/04 Dim 2 + Insight 5 +
Recommendation keyboard-navigation.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-10 17:10:59 +02:00
commit 224517f205
2 changed files with 81 additions and 0 deletions

View file

@ -2644,6 +2644,67 @@ playground first-run shows a complete round-trip-able artifact.
// handler, popstate restoration, and ?project= URL deep-link hint.
wireDashboardNavigation();
maybeShowProjectURLHint();
// Step 20 (v4.3) — J/K annotation nav + Esc clear + ] toggle sidebar
wireKeyboardNav();
}
// v4.3 Step 20 — document-level keyboard navigation.
// J = next annotation, K = prev, ] = toggle sidebar, Escape = clear
// active anchor. Aria-live region announces position + target.
// Skip when user is typing in an input/textarea/contenteditable so the
// playground never steals keystrokes from form fields.
function wireKeyboardNav() {
document.addEventListener('keydown', function (e) {
var t = e.target;
if (t && t.matches && t.matches('input, textarea, select, [contenteditable], [contenteditable="true"]')) return;
if (e.ctrlKey || e.altKey || e.metaKey) return;
if (e.key === ']') {
e.preventDefault();
toggleSidebar();
return;
}
if (e.key === 'Escape') {
var actives = document.querySelectorAll('.voyage-anchor-active');
if (actives.length === 0) return;
for (var i = 0; i < actives.length; i++) actives[i].classList.remove('voyage-anchor-active');
var listActives = document.querySelectorAll('#voyage-jumplist li[data-active="true"]');
for (var j = 0; j < listActives.length; j++) listActives[j].removeAttribute('data-active');
announce('Annotering avbrutt.');
return;
}
if (e.key === 'j' || e.key === 'k') {
e.preventDefault();
var direction = e.key === 'j' ? 1 : -1;
var ta = $('voyage-paste-input');
var fm = quickParseFrontmatter(ta ? ta.value : '');
var key = deriveStorageKey(fm);
var drafts = loadDrafts(key);
if (!Array.isArray(drafts) || drafts.length === 0) return;
var sorted = drafts.slice().sort(function (a, b) {
return (Number(a.line) || 0) - (Number(b.line) || 0);
});
var allBadges = document.querySelectorAll('.voyage-anchor-badge[data-anchor-id]');
var currentId = null;
for (var k = 0; k < allBadges.length; k++) {
if (allBadges[k].parentElement && allBadges[k].parentElement.classList.contains('voyage-anchor-active')) {
currentId = allBadges[k].getAttribute('data-anchor-id');
break;
}
}
var curIdx = currentId ? sorted.findIndex(function (d) { return d.id === currentId; }) : -1;
var nextIdx = curIdx === -1
? (direction === 1 ? 0 : sorted.length - 1)
: (curIdx + direction + sorted.length) % sorted.length;
var nextDraft = sorted[nextIdx];
if (!nextDraft) return;
setActiveAnchor(nextDraft.id);
var snippet = nextDraft.snippet ? (' — ' + String(nextDraft.snippet).slice(0, 60)) : '';
announce('Annotering ' + (nextIdx + 1) + ' av ' + sorted.length + ': ' + (nextDraft.target_anchor || 'page') + snippet);
return;
}
});
}
function setThemeLabel(theme) {

View file

@ -279,3 +279,23 @@ test('voyage-playground.html declares renderAnnotationList JS function (v4.3 Ste
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+renderAnnotationList\s*\(\s*\)/, 'renderAnnotationList() function required');
});
test('voyage-playground.html declares wireKeyboardNav with j/k/]/Escape (v4.3 Step 20)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /function\s+wireKeyboardNav\s*\(\s*\)/, 'wireKeyboardNav() function required');
assert.match(text, /e\.key === 'j'/, "'j' key handler required");
assert.match(text, /e\.key === 'k'/, "'k' key handler required");
assert.match(text, /e\.key === '\]'/, "']' key (toggle-sidebar) required");
assert.match(text, /e\.key === 'Escape'/, "'Escape' key handler required");
});
test('voyage-playground.html keyboard nav skips inputs/textareas (v4.3 Step 20)', () => {
const text = readFileSync(HTML, 'utf-8');
assert.match(text, /matches\([^)]*input[^)]*textarea/, 'input/textarea matches() guard required');
});
test('voyage-playground.html keyboard nav announces via aria-live region (v4.3 Step 20)', () => {
const text = readFileSync(HTML, 'utf-8');
// The wireKeyboardNav body contains announce(... ' av ' ...) for nav-position announce
assert.match(text, /announce\('Annotering '/, 'aria-live announce on annotation navigation required');
});