From 224517f205e322e7c9fafc55fa9e2f831fd52b30 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Sun, 10 May 2026 17:10:59 +0200 Subject: [PATCH] feat(voyage): implement J/K keyboard navigation + Esc + aria-live announces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: ". 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. --- .../voyage/playground/voyage-playground.html | 61 +++++++++++++++++++ .../playground/voyage-playground.test.mjs | 20 ++++++ 2 files changed, 81 insertions(+) diff --git a/plugins/voyage/playground/voyage-playground.html b/plugins/voyage/playground/voyage-playground.html index aa8bccc..eefd404 100644 --- a/plugins/voyage/playground/voyage-playground.html +++ b/plugins/voyage/playground/voyage-playground.html @@ -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) { diff --git a/plugins/voyage/tests/playground/voyage-playground.test.mjs b/plugins/voyage/tests/playground/voyage-playground.test.mjs index 55a9581..dbb8a78 100644 --- a/plugins/voyage/tests/playground/voyage-playground.test.mjs +++ b/plugins/voyage/tests/playground/voyage-playground.test.mjs @@ -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'); +});