feat: keyboard shortcuts — backtick, number keys, arrow navigation in handleGlobalKeydown

- Backtick (Backquote) toggles view dropdown in grid mode only (not fullscreen)
- Number keys 1-9 directly switch views (1=all, 9=hidden, 2-8=user views)
- Arrow keys navigate within open dropdown using [role="menuitem"] elements
- Enter activates the focused dropdown item
- Escape closes open dropdown when one is visible
- Shortcuts disabled when typing in INPUT/TEXTAREA/SELECT
- Input guard applied globally (not just for comma shortcut)

Tests added:
- test_handle_global_keydown_has_backtick_handler
- test_handle_global_keydown_has_number_key_shortcuts
- test_backtick_only_on_grid_not_fullscreen

All 229 tests pass (226 existing + 3 new)
This commit is contained in:
Brian Krabach
2026-04-15 18:00:53 -07:00
parent 9180fbbfa9
commit a891fada46
2 changed files with 131 additions and 10 deletions
+67 -10
View File
@@ -1953,25 +1953,82 @@ function switchSettingsTab(tabName) {
/** /**
* Global keydown handler. * Global keydown handler.
* If settings are open: Escape closes settings. * Settings open: Escape closes settings, return.
* Comma key (not in inputs) opens settings. * Ignore shortcuts when typing in INPUT/TEXTAREA/SELECT.
* In fullscreen: Escape returns to grid. * Comma key (not in inputs, no ctrl/meta) opens settings.
* Grid overview only: backtick toggles dropdown, number keys 1-9 switch views.
* Arrow keys + Enter navigate within open dropdown.
* Escape closes open dropdown.
* Fullscreen: Escape calls closeSession().
* @param {KeyboardEvent} e * @param {KeyboardEvent} e
*/ */
function handleGlobalKeydown(e) { function handleGlobalKeydown(e) {
// Settings open: only Escape closes it, then bail
if (_settingsOpen) { if (_settingsOpen) {
if (e.key === 'Escape') { if (e.key === 'Escape') { closeSettings(); }
closeSettings();
}
return; return;
} }
if (e.key === ',' && !e.ctrlKey && !e.metaKey) { // Determine if focus is inside a text input
const tag = document.activeElement && document.activeElement.tagName; const tag = document.activeElement && document.activeElement.tagName;
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') { const inInput = (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT');
openSettings(); // Comma key (not in inputs, no ctrl/meta) opens settings
if (e.key === ',' && !e.ctrlKey && !e.metaKey && !inInput) {
openSettings();
return;
}
// View dropdown shortcuts — grid overview only, not in input, no ctrl/meta
if (_viewMode === 'grid' && !inInput && !e.ctrlKey && !e.metaKey) {
// Backtick toggles the view dropdown
if (e.code === 'Backquote') {
e.preventDefault();
toggleViewDropdown();
return;
}
// Number keys 1-9: 1=all, 9=hidden, 2-8=user views by index (num-2)
if (e.code && e.code.startsWith('Digit')) {
const num = parseInt(e.key, 10);
if (num >= 1 && num <= 9) {
const views = (_serverSettings && _serverSettings.views) || [];
if (num === 1) {
switchView('all');
} else if (num === 9) {
switchView('hidden');
} else {
const vIdx = num - 2;
if (vIdx < views.length) { switchView(views[vIdx].name); }
}
return;
}
}
}
// Arrow key navigation within open dropdown
const menu = $('view-dropdown-menu');
if (menu && !menu.classList.contains('hidden')) {
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
const items = Array.from(menu.querySelectorAll('[role="menuitem"]'));
if (items.length > 0) {
const focusedEl = document.activeElement;
let itemIdx = items.indexOf(focusedEl);
if (e.key === 'ArrowDown') {
itemIdx = (itemIdx + 1) % items.length;
} else {
itemIdx = (itemIdx - 1 + items.length) % items.length;
}
items[itemIdx].focus();
}
return;
}
if (e.key === 'Enter') {
const focusedEl = document.activeElement;
if (menu.contains(focusedEl)) { focusedEl.click(); return; }
}
if (e.key === 'Escape') {
closeViewDropdown();
return; return;
} }
} }
// Fullscreen: Escape calls closeSession
if (_viewMode === 'fullscreen' && e.key === 'Escape') { if (_viewMode === 'fullscreen' && e.key === 'Escape') {
e.preventDefault(); e.preventDefault();
closeSession(); closeSession();
+64
View File
@@ -3165,3 +3165,67 @@ def test_switch_view_patches_state() -> None:
body = match.group(1) body = match.group(1)
assert "PATCH" in body, "switchView must use PATCH method" assert "PATCH" in body, "switchView must use PATCH method"
assert "/api/state" in body, "switchView must PATCH /api/state" assert "/api/state" in body, "switchView must PATCH /api/state"
# ---------------------------------------------------------------------------
# Keyboard Shortcuts — Backtick, Number Keys, Arrow Navigation (task-7)
# ---------------------------------------------------------------------------
def test_handle_global_keydown_has_backtick_handler() -> None:
"""handleGlobalKeydown must handle backtick/Backquote key to toggle view dropdown."""
match = re.search(
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "handleGlobalKeydown function not found"
body = match.group(1)
has_backtick = (
"Backquote" in body
or "e.key === '`'" in body
or 'e.key === "`"' in body
)
assert has_backtick, (
"handleGlobalKeydown must handle backtick/Backquote key to toggle view dropdown"
)
assert "toggleViewDropdown" in body, (
"handleGlobalKeydown backtick handler must call toggleViewDropdown"
)
def test_handle_global_keydown_has_number_key_shortcuts() -> None:
"""handleGlobalKeydown must call switchView for number keys 19."""
match = re.search(
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "handleGlobalKeydown function not found"
body = match.group(1)
assert "switchView" in body, (
"handleGlobalKeydown must call switchView for number keys 19"
)
has_number_handling = (
"Digit" in body or "parseInt" in body
)
assert has_number_handling, (
"handleGlobalKeydown must handle number key codes (e.g. e.code.startsWith('Digit'))"
)
def test_backtick_only_on_grid_not_fullscreen() -> None:
"""Backtick handler must check _viewMode === 'grid' (not active in fullscreen)."""
match = re.search(
r"function handleGlobalKeydown\s*\(e\)\s*\{(.*?)\n\}",
_JS,
re.DOTALL,
)
assert match, "handleGlobalKeydown function not found"
body = match.group(1)
assert "_viewMode" in body, (
"handleGlobalKeydown backtick handler must check _viewMode"
)
assert "'grid'" in body, (
"handleGlobalKeydown must check _viewMode === 'grid' before handling backtick/number shortcuts"
)