diff --git a/static/sessions.js b/static/sessions.js index 61aa8087..c485185e 100644 --- a/static/sessions.js +++ b/static/sessions.js @@ -3778,6 +3778,7 @@ let _sessionActionMenu = null; let _sessionActionAnchor = null; let _sessionActionSessionId = null; let _sessionActionMenuId = 0; +let _sessionActionPreviousFocus = null; const _expandedChildSessionKeys = new Set(); const _expandedLineageKeys = new Set(); const _lineageReportCache = new Map(); @@ -4176,8 +4177,15 @@ function _showBatchProjectPicker(){ setTimeout(()=>document.addEventListener('click',close),0); } +function _focusSessionActionMenuRestoreTarget(target){ + if(!target||!target.isConnected||typeof target.focus!=='function') return false; + try{target.focus({preventScroll:true});}catch(_){target.focus();} + return document.activeElement===target; +} + function closeSessionActionMenu({restoreFocus=false}={}){ const focusTarget=restoreFocus?_sessionActionAnchor:null; + const fallbackFocusTarget=restoreFocus?_sessionActionPreviousFocus:null; if(_sessionActionMenu){ _sessionActionMenu.remove(); _sessionActionMenu = null; @@ -4193,9 +4201,8 @@ function closeSessionActionMenu({restoreFocus=false}={}){ _sessionActionAnchor = null; } _sessionActionSessionId = null; - if(focusTarget&&focusTarget.isConnected&&typeof focusTarget.focus==='function'){ - try{focusTarget.focus({preventScroll:true});}catch(_){focusTarget.focus();} - } + _sessionActionPreviousFocus = null; + if(!_focusSessionActionMenuRestoreTarget(focusTarget)) _focusSessionActionMenuRestoreTarget(fallbackFocusTarget); } function _sessionActionMenuShouldIgnoreScrollTarget(target){ @@ -4317,6 +4324,7 @@ async function _copySessionLink(session){ } function _mountSessionActionMenu(menu, session, anchorEl){ + _sessionActionPreviousFocus=document.activeElement; document.body.appendChild(menu); _sessionActionMenu = menu; _sessionActionAnchor = anchorEl; diff --git a/tests/test_session_action_menu_focus.py b/tests/test_session_action_menu_focus.py index e5ac54e7..dea18b13 100644 --- a/tests/test_session_action_menu_focus.py +++ b/tests/test_session_action_menu_focus.py @@ -41,9 +41,11 @@ def _fixture_script() -> str: "let _sessionActionMenu = null;", "let _sessionActionAnchor = null;", "let _sessionActionSessionId = null;", + "let _sessionActionPreviousFocus = null;", "const esc = value => String(value);", "function _positionSessionActionMenu(){}", "function _playSessionActionMenuEntrance(){}", + _function_source("_focusSessionActionMenuRestoreTarget"), _function_source("closeSessionActionMenu"), _function_source("_buildSessionAction"), _function_source("_mountSessionActionMenu"), @@ -89,6 +91,33 @@ def _fixture_script() -> str: row.remove(); return result; }; + + window.__sessionActionMenuNonFocusableOpenerResult = () => { + const priorFocus = document.createElement('button'); + priorFocus.textContent = 'Prior keyboard focus'; + const row = document.createElement('div'); + row.className = 'session-item'; + row.textContent = 'Non-focusable session row'; + document.body.append(priorFocus, row); + priorFocus.focus(); + + const menu = document.createElement('div'); + menu.className = 'session-action-menu'; + menu.id = 'sessionActionMenu-nonfocusable-opener-test'; + menu.setAttribute('role', 'menu'); + menu.setAttribute('aria-label', 'Conversation actions'); + menu.appendChild(_buildSessionAction('Rename conversation', '', '', () => {})); + _mountSessionActionMenu(menu, {session_id: 'nonfocusable-opener-test'}, row); + menu.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true})); + + const result = { + menuRemovedOnEscape: !document.querySelector('.session-action-menu'), + focusReturnedToPreviousControl: document.activeElement === priorFocus, + }; + priorFocus.remove(); + row.remove(); + return result; + }; """, ] ) @@ -124,3 +153,26 @@ def test_session_action_menu_focus_lifecycle_in_browser(): "expandedAfterEscape": "false", "controlsAfterEscape": None, } + + +def test_session_action_menu_returns_to_prior_focus_for_nonfocusable_opener_in_browser(): + try: + from playwright.sync_api import sync_playwright + except Exception: # pragma: no cover - dependency missing path + pytest.skip("playwright is unavailable; run the session action menu browser test") + + with sync_playwright() as playwright: + browser = playwright.chromium.launch( + headless=True, + args=["--no-sandbox", "--disable-dev-shm-usage"], + ) + page = browser.new_page() + page.set_content("") + page.add_script_tag(content=_fixture_script()) + result = page.evaluate("window.__sessionActionMenuNonFocusableOpenerResult()") + browser.close() + + assert result == { + "menuRemovedOnEscape": True, + "focusReturnedToPreviousControl": True, + } diff --git a/tests/test_session_touch_actions.py b/tests/test_session_touch_actions.py index 62395174..2e7f2af1 100644 --- a/tests/test_session_touch_actions.py +++ b/tests/test_session_touch_actions.py @@ -55,7 +55,9 @@ def test_session_action_menu_exposes_popup_state_and_focus_contract(): assert "_sessionActionAnchor.setAttribute('aria-expanded','false');" in close assert "_sessionActionAnchor.removeAttribute('aria-controls');" in close assert "restoreFocus" in close - assert "focusTarget.focus({preventScroll:true});" in close + assert "fallbackFocusTarget=restoreFocus?_sessionActionPreviousFocus:null;" in close + assert "_focusSessionActionMenuRestoreTarget(focusTarget)" in close + assert "_focusSessionActionMenuRestoreTarget(fallbackFocusTarget)" in close escape_handler = _sessions_block("document.addEventListener('keydown',e=>{", "window.addEventListener('resize'") assert "closeSessionActionMenu({restoreFocus:true});" in escape_handler