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'); +});