Restore focus from non-button session action menus

This commit is contained in:
sheldon-im authored and nesquena-hermes committed 2026-07-13 19:24:09 +00:00
1 parent bc85711af2
commit d7c2cc2d5d
3 files changed
+66 -4

No files matched your search

+11 -3
View File
@@ -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;
+52
View File
@@ -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("<!doctype html><html><body></body></html>")
page.add_script_tag(content=_fixture_script())
result = page.evaluate("window.__sessionActionMenuNonFocusableOpenerResult()")
browser.close()
assert result == {
"menuRemovedOnEscape": True,
"focusReturnedToPreviousControl": True,
}
+3 -1
View File
@@ -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