diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index 1a092e9..f2f3fc3 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1953,25 +1953,82 @@ function switchSettingsTab(tabName) { /** * Global keydown handler. - * If settings are open: Escape closes settings. - * Comma key (not in inputs) opens settings. - * In fullscreen: Escape returns to grid. + * Settings open: Escape closes settings, return. + * Ignore shortcuts when typing in INPUT/TEXTAREA/SELECT. + * 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 */ function handleGlobalKeydown(e) { + // Settings open: only Escape closes it, then bail if (_settingsOpen) { - if (e.key === 'Escape') { - closeSettings(); - } + if (e.key === 'Escape') { closeSettings(); } return; } - if (e.key === ',' && !e.ctrlKey && !e.metaKey) { - const tag = document.activeElement && document.activeElement.tagName; - if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') { - openSettings(); + // Determine if focus is inside a text input + const tag = document.activeElement && document.activeElement.tagName; + const inInput = (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT'); + // 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; } } + // Fullscreen: Escape calls closeSession if (_viewMode === 'fullscreen' && e.key === 'Escape') { e.preventDefault(); closeSession(); diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index dbb6fd1..85f8727 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -3165,3 +3165,67 @@ def test_switch_view_patches_state() -> None: body = match.group(1) assert "PATCH" in body, "switchView must use PATCH method" 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 1–9.""" + 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 1–9" + ) + 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" + )