Improve conversation actions menu keyboard access
This commit is contained in:
1 parent
41f1eb5624
commit
bc85711af2
3 files changed
+205
-3
No files matched your search
+44
-3
@@ -3777,6 +3777,7 @@ _restoreSessionSourceFilter();
|
||||
let _sessionActionMenu = null;
|
||||
let _sessionActionAnchor = null;
|
||||
let _sessionActionSessionId = null;
|
||||
let _sessionActionMenuId = 0;
|
||||
const _expandedChildSessionKeys = new Set();
|
||||
const _expandedLineageKeys = new Set();
|
||||
const _lineageReportCache = new Map();
|
||||
@@ -4175,7 +4176,8 @@ function _showBatchProjectPicker(){
|
||||
setTimeout(()=>document.addEventListener('click',close),0);
|
||||
}
|
||||
|
||||
function closeSessionActionMenu(){
|
||||
function closeSessionActionMenu({restoreFocus=false}={}){
|
||||
const focusTarget=restoreFocus?_sessionActionAnchor:null;
|
||||
if(_sessionActionMenu){
|
||||
_sessionActionMenu.remove();
|
||||
_sessionActionMenu = null;
|
||||
@@ -4183,12 +4185,17 @@ function closeSessionActionMenu(){
|
||||
if(_sessionActionAnchor){
|
||||
if(_sessionActionAnchor.classList&&_sessionActionAnchor.classList.contains('session-actions-trigger')){
|
||||
_sessionActionAnchor.classList.remove('active');
|
||||
_sessionActionAnchor.setAttribute('aria-expanded','false');
|
||||
_sessionActionAnchor.removeAttribute('aria-controls');
|
||||
}
|
||||
const row=_sessionActionAnchor.closest('.session-item,.session-child-session');
|
||||
if(row) row.classList.remove('menu-open','long-pressing');
|
||||
_sessionActionAnchor = null;
|
||||
}
|
||||
_sessionActionSessionId = null;
|
||||
if(focusTarget&&focusTarget.isConnected&&typeof focusTarget.focus==='function'){
|
||||
try{focusTarget.focus({preventScroll:true});}catch(_){focusTarget.focus();}
|
||||
}
|
||||
}
|
||||
|
||||
function _sessionActionMenuShouldIgnoreScrollTarget(target){
|
||||
@@ -4240,6 +4247,7 @@ function _buildSessionAction(label, meta, icon, onSelect, extraClass=''){
|
||||
const opt=document.createElement('button');
|
||||
opt.type='button';
|
||||
opt.className='ws-opt session-action-opt'+(extraClass?` ${extraClass}`:'');
|
||||
opt.setAttribute('role','menuitem');
|
||||
// Compact context-menu shape (#3223 redesign, Nathan 2026-06-01): show only
|
||||
// icon + label, matching VS Code / browser / ChatGPT conversation menus. The
|
||||
// descriptive `meta` is preserved as a hover tooltip (title=) so the
|
||||
@@ -4313,11 +4321,39 @@ function _mountSessionActionMenu(menu, session, anchorEl){
|
||||
_sessionActionMenu = menu;
|
||||
_sessionActionAnchor = anchorEl;
|
||||
_sessionActionSessionId = session.session_id;
|
||||
if(anchorEl.classList&&anchorEl.classList.contains('session-actions-trigger')) anchorEl.classList.add('active');
|
||||
if(anchorEl.classList&&anchorEl.classList.contains('session-actions-trigger')){
|
||||
anchorEl.classList.add('active');
|
||||
anchorEl.setAttribute('aria-expanded','true');
|
||||
anchorEl.setAttribute('aria-controls',menu.id);
|
||||
}
|
||||
const row=anchorEl.closest('.session-item,.session-child-session');
|
||||
if(row) row.classList.add('menu-open');
|
||||
_positionSessionActionMenu(anchorEl);
|
||||
_playSessionActionMenuEntrance(menu);
|
||||
const menuItems=()=>Array.from(menu.querySelectorAll('.session-action-opt:not([disabled])'));
|
||||
menu.addEventListener('keydown',e=>{
|
||||
const items=menuItems();
|
||||
if(e.key==='Escape'){
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeSessionActionMenu({restoreFocus:true});
|
||||
return;
|
||||
}
|
||||
if(!items.length) return;
|
||||
const currentIndex=Math.max(0,items.indexOf(document.activeElement));
|
||||
let nextIndex=null;
|
||||
if(e.key==='ArrowDown') nextIndex=(currentIndex+1)%items.length;
|
||||
else if(e.key==='ArrowUp') nextIndex=(currentIndex-1+items.length)%items.length;
|
||||
else if(e.key==='Home') nextIndex=0;
|
||||
else if(e.key==='End') nextIndex=items.length-1;
|
||||
if(nextIndex===null) return;
|
||||
e.preventDefault();
|
||||
try{items[nextIndex].focus({preventScroll:true});}catch(_){items[nextIndex].focus();}
|
||||
});
|
||||
const firstAction=menuItems()[0];
|
||||
if(firstAction){
|
||||
try{firstAction.focus({preventScroll:true});}catch(_){firstAction.focus();}
|
||||
}
|
||||
}
|
||||
|
||||
function _findSessionRenameRow(sessionId){
|
||||
@@ -4622,6 +4658,9 @@ function _openSessionActionMenu(session, anchorEl){
|
||||
const isExternalSession = isMessagingSession || isCliSession;
|
||||
const menu=document.createElement('div');
|
||||
menu.className='session-action-menu';
|
||||
menu.id='sessionActionMenu-'+(++_sessionActionMenuId);
|
||||
menu.setAttribute('role','menu');
|
||||
menu.setAttribute('aria-label', 'Conversation actions');
|
||||
_appendSessionCopyLinkAction(menu, session);
|
||||
if(isReadOnly){
|
||||
_appendSessionExportHtmlAction(menu, session);
|
||||
@@ -4812,7 +4851,7 @@ document.addEventListener('scroll',e=>{
|
||||
closeSessionActionMenu();
|
||||
}, true);
|
||||
document.addEventListener('keydown',e=>{
|
||||
if(e.key==='Escape' && _sessionActionMenu) closeSessionActionMenu();
|
||||
if(e.key==='Escape' && _sessionActionMenu) closeSessionActionMenu({restoreFocus:true});
|
||||
});
|
||||
window.addEventListener('resize',()=>{
|
||||
if(_sessionActionMenu && _sessionActionAnchor) _positionSessionActionMenu(_sessionActionAnchor);
|
||||
@@ -8250,6 +8289,7 @@ function renderSessionListFromCache(){
|
||||
menuBtn.className='session-actions-trigger';
|
||||
menuBtn.title='Conversation actions';
|
||||
menuBtn.setAttribute('aria-haspopup','menu');
|
||||
menuBtn.setAttribute('aria-expanded','false');
|
||||
menuBtn.setAttribute('aria-label','Conversation actions');
|
||||
menuBtn.innerHTML=ICONS.more;
|
||||
const stopMenuPointer=(e)=>e.stopPropagation();
|
||||
@@ -8348,6 +8388,7 @@ function renderSessionListFromCache(){
|
||||
menuBtn.className='session-actions-trigger';
|
||||
menuBtn.title='Conversation actions';
|
||||
menuBtn.setAttribute('aria-haspopup','menu');
|
||||
menuBtn.setAttribute('aria-expanded','false');
|
||||
menuBtn.setAttribute('aria-label','Conversation actions');
|
||||
menuBtn.innerHTML=ICONS.more;
|
||||
const stopMenuPointer=(e)=>e.stopPropagation();
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"""Browser regression coverage for the portaled conversation-actions menu."""
|
||||
from pathlib import Path
|
||||
import re
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
SESSIONS_JS = (Path(__file__).resolve().parents[1] / "static" / "sessions.js").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def _function_source(name: str) -> str:
|
||||
marker = f"function {name}"
|
||||
start = SESSIONS_JS.find(marker)
|
||||
assert start >= 0, f"{name} not found"
|
||||
signature_end = re.search(r"\)\s*\{", SESSIONS_JS[start:])
|
||||
assert signature_end, f"{name} signature did not close"
|
||||
brace = start + signature_end.end() - 1
|
||||
depth = 1
|
||||
index = brace + 1
|
||||
while depth and index < len(SESSIONS_JS):
|
||||
if SESSIONS_JS[index] == "{":
|
||||
depth += 1
|
||||
elif SESSIONS_JS[index] == "}":
|
||||
depth -= 1
|
||||
index += 1
|
||||
assert depth == 0, f"{name} body did not close"
|
||||
return SESSIONS_JS[start:index]
|
||||
|
||||
|
||||
def _fixture_script() -> str:
|
||||
"""Run the production menu lifecycle in a small real-DOM fixture.
|
||||
|
||||
The menu is deliberately portaled to body in production. This fixture stubs
|
||||
only positioning/animation helpers, so the browser verifies the real focus,
|
||||
ARIA, and keyboard lifecycle without needing a live agent session.
|
||||
"""
|
||||
return "\n".join(
|
||||
[
|
||||
"let _sessionActionMenu = null;",
|
||||
"let _sessionActionAnchor = null;",
|
||||
"let _sessionActionSessionId = null;",
|
||||
"const esc = value => String(value);",
|
||||
"function _positionSessionActionMenu(){}",
|
||||
"function _playSessionActionMenuEntrance(){}",
|
||||
_function_source("closeSessionActionMenu"),
|
||||
_function_source("_buildSessionAction"),
|
||||
_function_source("_mountSessionActionMenu"),
|
||||
"""
|
||||
window.__sessionActionMenuFocusResult = () => {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'session-item';
|
||||
const trigger = document.createElement('button');
|
||||
trigger.className = 'session-actions-trigger';
|
||||
trigger.setAttribute('aria-haspopup', 'menu');
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.setAttribute('aria-label', 'Conversation actions');
|
||||
row.appendChild(trigger);
|
||||
document.body.appendChild(row);
|
||||
trigger.focus();
|
||||
|
||||
const menu = document.createElement('div');
|
||||
menu.className = 'session-action-menu';
|
||||
menu.id = 'sessionActionMenu-browser-test';
|
||||
menu.setAttribute('role', 'menu');
|
||||
menu.setAttribute('aria-label', 'Conversation actions');
|
||||
menu.appendChild(_buildSessionAction('Copy conversation link', '', '', () => {}));
|
||||
menu.appendChild(_buildSessionAction('Rename conversation', '', '', () => {}));
|
||||
menu.appendChild(_buildSessionAction('Delete conversation', '', '', () => {}));
|
||||
_mountSessionActionMenu(menu, {session_id: 'browser-focus-test'}, trigger);
|
||||
|
||||
const result = {
|
||||
expandedOnOpen: trigger.getAttribute('aria-expanded'),
|
||||
controlsOnOpen: trigger.getAttribute('aria-controls'),
|
||||
menuRole: menu.getAttribute('role'),
|
||||
firstActionFocused: document.activeElement === menu.querySelector('.session-action-opt'),
|
||||
firstActionRole: document.activeElement.getAttribute('role'),
|
||||
};
|
||||
menu.dispatchEvent(new KeyboardEvent('keydown', {key: 'ArrowDown', bubbles: true}));
|
||||
result.arrowDownText = document.activeElement.textContent.trim();
|
||||
menu.dispatchEvent(new KeyboardEvent('keydown', {key: 'End', bubbles: true}));
|
||||
result.endText = document.activeElement.textContent.trim();
|
||||
menu.dispatchEvent(new KeyboardEvent('keydown', {key: 'Escape', bubbles: true}));
|
||||
result.menuRemovedOnEscape = !document.querySelector('.session-action-menu');
|
||||
result.focusRestoredOnEscape = document.activeElement === trigger;
|
||||
result.expandedAfterEscape = trigger.getAttribute('aria-expanded');
|
||||
result.controlsAfterEscape = trigger.getAttribute('aria-controls');
|
||||
row.remove();
|
||||
return result;
|
||||
};
|
||||
""",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_session_action_menu_focus_lifecycle_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.__sessionActionMenuFocusResult()")
|
||||
browser.close()
|
||||
|
||||
assert result == {
|
||||
"expandedOnOpen": "true",
|
||||
"controlsOnOpen": "sessionActionMenu-browser-test",
|
||||
"menuRole": "menu",
|
||||
"firstActionFocused": True,
|
||||
"firstActionRole": "menuitem",
|
||||
"arrowDownText": "Rename conversation",
|
||||
"endText": "Delete conversation",
|
||||
"menuRemovedOnEscape": True,
|
||||
"focusRestoredOnEscape": True,
|
||||
"expandedAfterEscape": "false",
|
||||
"controlsAfterEscape": None,
|
||||
}
|
||||
@@ -33,6 +33,41 @@ def test_session_menu_respects_motion_preferences():
|
||||
assert ".session-action-menu{animation:none;will-change:auto;}" in STYLE_CSS
|
||||
|
||||
|
||||
def test_session_action_menu_exposes_popup_state_and_focus_contract():
|
||||
"""A portaled menu must stay discoverable from its sidebar trigger.
|
||||
|
||||
The fixed-position menu intentionally lives under document.body to avoid
|
||||
clipping, so a screen reader cannot rely on DOM proximity. Opening it must
|
||||
expose the popup relationship and move focus to an actionable item; Escape
|
||||
must return focus to the trigger.
|
||||
"""
|
||||
assert SESSIONS_JS.count("menuBtn.setAttribute('aria-expanded','false');") == 2
|
||||
assert "menu.setAttribute('role','menu');" in SESSIONS_JS
|
||||
assert "menu.setAttribute('aria-label', 'Conversation actions');" in SESSIONS_JS
|
||||
assert "opt.setAttribute('role','menuitem');" in SESSIONS_JS
|
||||
|
||||
mount = _sessions_block("function _mountSessionActionMenu(menu, session, anchorEl){", "function _findSessionRenameRow")
|
||||
assert "anchorEl.setAttribute('aria-expanded','true');" in mount
|
||||
assert "anchorEl.setAttribute('aria-controls',menu.id);" in mount
|
||||
assert "firstAction.focus({preventScroll:true});" in mount
|
||||
|
||||
close = _sessions_block("function closeSessionActionMenu", "function _sessionActionMenuShouldIgnoreScrollTarget")
|
||||
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
|
||||
|
||||
escape_handler = _sessions_block("document.addEventListener('keydown',e=>{", "window.addEventListener('resize'")
|
||||
assert "closeSessionActionMenu({restoreFocus:true});" in escape_handler
|
||||
|
||||
|
||||
def test_session_action_menu_supports_arrow_key_navigation():
|
||||
mount = _sessions_block("function _mountSessionActionMenu(menu, session, anchorEl){", "function _findSessionRenameRow")
|
||||
for key in ("'ArrowDown'", "'ArrowUp'", "'Home'", "'End'"):
|
||||
assert key in mount
|
||||
assert "menu.addEventListener('keydown'" in mount
|
||||
|
||||
|
||||
def test_mobile_session_menu_opens_from_long_press_and_hides_dots():
|
||||
assert "const SESSION_LONG_PRESS_DELAY_MS = 400;" in SESSIONS_JS
|
||||
assert "el.classList.add('long-pressing')" in SESSIONS_JS
|
||||
|
||||
Reference in new issue
Block a user