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:
parent
6db7c72511
commit
224517f205
2 changed files with 81 additions and 0 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue