diff --git a/muxplex/frontend/app.js b/muxplex/frontend/app.js index ea3bb55..c7ee62c 100644 --- a/muxplex/frontend/app.js +++ b/muxplex/frontend/app.js @@ -1324,6 +1324,51 @@ function showNewSessionInput(btn) { input.focus(); } +/** + * Show a fixed-position input overlay for creating a new session from the mobile FAB. + * Unlike showNewSessionInput (which inserts inline into btn.parentNode), this renders + * a fixed-position overlay appended directly to document.body — ensuring it is always + * visible on mobile regardless of body/view overflow:hidden constraints. + */ +function showFabSessionInput() { + const fab = $('new-session-fab'); + + const overlay = document.createElement('div'); + overlay.className = 'fab-input-overlay'; + + const input = document.createElement('input'); + input.type = 'text'; + input.className = 'new-session-input'; + input.placeholder = 'Session name\u2026'; + input.autocomplete = 'off'; + input.spellcheck = false; + + overlay.appendChild(input); + + function cleanup() { + if (overlay.parentNode) overlay.parentNode.removeChild(overlay); + if (fab) fab.style.display = ''; + } + + input.addEventListener('keydown', function(e) { + if (e.key === 'Enter') { + const name = input.value.trim(); + cleanup(); + if (name) createNewSession(name); + } else if (e.key === 'Escape') { + cleanup(); + } + }); + + input.addEventListener('blur', function() { + setTimeout(cleanup, 150); + }); + + if (fab) fab.style.display = 'none'; + document.body.appendChild(overlay); + input.focus(); +} + /** * Create a new tmux session via POST /api/sessions. * Shows a toast with the created session name, calls pollSessions() to refresh, @@ -1357,7 +1402,7 @@ function bindStaticEventListeners() { var sidebarNewSessionBtn = $('sidebar-new-session-btn'); if (sidebarNewSessionBtn) on(sidebarNewSessionBtn, 'click', function() { showNewSessionInput(sidebarNewSessionBtn); }); var newSessionFab = $('new-session-fab'); - if (newSessionFab) on(newSessionFab, 'click', function() { showNewSessionInput(newSessionFab); }); + if (newSessionFab) on(newSessionFab, 'click', showFabSessionInput); on($('sidebar-toggle-btn'), 'click', toggleSidebar); on($('sidebar-collapse-btn'), 'click', toggleSidebar); bindSidebarClickAway(); @@ -1583,6 +1628,7 @@ if (typeof module !== 'undefined' && module.exports) { patchServerSetting, // Header + button with inline name input showNewSessionInput, + showFabSessionInput, createNewSession, // Test-only helpers _setCurrentSessions, diff --git a/muxplex/frontend/style.css b/muxplex/frontend/style.css index 1ad9f3f..01636a2 100644 --- a/muxplex/frontend/style.css +++ b/muxplex/frontend/style.css @@ -821,6 +821,10 @@ body { .session-sidebar { transition: none; } + + .new-session-fab:active { + transform: none; + } } /* ============================================================ @@ -1222,13 +1226,27 @@ body { cursor: pointer; z-index: 100; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); - transition: transform 150ms ease, box-shadow 150ms ease; + transition: transform 150ms ease; } .new-session-fab:active { transform: scale(0.95); } +.new-session-fab:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; +} + +/* Fixed-position input overlay triggered by mobile FAB — + appended to body so position:fixed escapes overflow:hidden on views */ +.fab-input-overlay { + position: fixed; + bottom: 80px; + right: 16px; + z-index: 200; +} + /* ============================================================ Responsive overlay sidebar at <960px ============================================================ */ diff --git a/muxplex/tests/test_frontend_js.py b/muxplex/tests/test_frontend_js.py index 6b10007..c78c04e 100644 --- a/muxplex/tests/test_frontend_js.py +++ b/muxplex/tests/test_frontend_js.py @@ -1858,3 +1858,73 @@ def test_js_fab_exported() -> None: assert "showNewSessionInput" in _JS, ( "showNewSessionInput must exist in app.js (used by FAB click handler)" ) + + +# ============================================================ +# Mobile FAB — dedicated fixed-position input (quality fix) +# ============================================================ + + +def test_js_show_fab_session_input_exists() -> None: + """showFabSessionInput function must exist in app.js (dedicated FAB input overlay).""" + assert "function showFabSessionInput" in _JS, ( + "showFabSessionInput must be defined in app.js — " + "FAB needs a fixed-position overlay, not inline body insertion" + ) + + +def test_js_show_fab_session_input_appends_to_body() -> None: + """showFabSessionInput must append the overlay to document.body (fixed positioning).""" + match = re.search( + r"function showFabSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showFabSessionInput function not found" + body = match.group(1) + assert "document.body" in body, ( + "showFabSessionInput must append to document.body " + "(fixed-position overlays must be direct body children to escape overflow:hidden)" + ) + + +def test_js_show_fab_session_input_handles_enter() -> None: + """showFabSessionInput must call createNewSession on Enter key.""" + match = re.search( + r"function showFabSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showFabSessionInput function not found" + body = match.group(1) + assert "Enter" in body, "showFabSessionInput must handle Enter key" + assert "createNewSession" in body, ( + "showFabSessionInput must call createNewSession on Enter" + ) + + +def test_js_show_fab_session_input_handles_escape() -> None: + """showFabSessionInput must handle Escape key to cancel.""" + match = re.search( + r"function showFabSessionInput\s*\(\s*\)\s*\{(.*?)(?=\nfunction |\nasync function |\n// )", + _JS, + re.DOTALL, + ) + assert match, "showFabSessionInput function not found" + body = match.group(1) + assert "Escape" in body, "showFabSessionInput must handle Escape key" + + +def test_js_fab_click_calls_show_fab_session_input() -> None: + """bindStaticEventListeners must bind FAB click to showFabSessionInput (not inline body insert).""" + match = re.search( + r"function bindStaticEventListeners\s*\(\s*\)\s*\{(.*?)\n\}", + _JS, + re.DOTALL, + ) + assert match, "bindStaticEventListeners function not found" + body = match.group(1) + assert "showFabSessionInput" in body, ( + "bindStaticEventListeners must call showFabSessionInput for FAB click — " + "the old showNewSessionInput(fab) inserts into body which is clipped by overflow:hidden" + )