From 67f841465623101b205012a6309a320028053f91 Mon Sep 17 00:00:00 2001 From: sheldon-im Date: Tue, 7 Jul 2026 09:46:35 +0900 Subject: [PATCH] Keep closed workspace dropdown out of SR navigation --- static/index.html | 2 +- static/panels.js | 55 +++++++++++++++++-- .../test_issue5671_workspace_switcher_a11y.py | 34 ++++++++++++ 3 files changed, 85 insertions(+), 6 deletions(-) diff --git a/static/index.html b/static/index.html index cdc19ff5..f34e28e1 100644 --- a/static/index.html +++ b/static/index.html @@ -751,7 +751,7 @@ - +
None
Minimal
diff --git a/static/panels.js b/static/panels.js index 353c1c7e..b0828882 100644 --- a/static/panels.js +++ b/static/panels.js @@ -5648,7 +5648,7 @@ function syncWorkspaceDisplays(){ const mobileAction=$('composerMobileWorkspaceAction'); const mobileLabel=$('composerMobileWorkspaceLabel'); const composerDropdown=$('composerWsDropdown'); - if(!hasWorkspace && composerDropdown) composerDropdown.classList.remove('open'); + if(!hasWorkspace && composerDropdown) _setWorkspaceDropdownOpenState(composerDropdown,false); // Only show workspace label once boot has finished to prevent // flash of "No workspace" before the saved session finishes loading. if(composerLabel) composerLabel.textContent=S._bootReady?label:''; @@ -5680,6 +5680,41 @@ async function loadWorkspaceList(){ }catch(e){ return {workspaces:[], last:''}; } } +function _setWorkspaceDropdownOpenState(dd,open){ + if(!dd)return; + dd.classList.toggle('open',!!open); + dd.hidden=!open; + dd.setAttribute('aria-hidden',open?'false':'true'); + if(open){ + try{dd.inert=false;}catch(_){} + dd.removeAttribute('inert'); + }else{ + try{dd.inert=true;}catch(_){} + dd.setAttribute('inert',''); + } +} + +function _getComposerWorkspaceFocusTarget(){ + const panel=(typeof $==='function')?$('composerMobileConfigPanel'):null; + const mobileAction=(typeof $==='function')?$('composerMobileWorkspaceAction'):null; + if(panel&&panel.classList.contains('open')&&mobileAction&&!mobileAction.disabled) return mobileAction; + return (typeof $==='function')?$('composerWorkspaceChip'):null; +} + +function _focusComposerWorkspaceTarget(target){ + if(target&&!target.disabled&&typeof target.focus==='function'){ + try{target.focus({preventScroll:true});} + catch(_){target.focus();} + } +} + +function _shouldRestoreComposerWorkspaceFocus(dd){ + if(typeof document==='undefined') return true; + const active=document.activeElement; + if(!active||active===document.body) return true; + return !!(dd&&dd.contains(active)); +} + function _renderWorkspaceAction(label, meta, iconSvg, onClick){ const opt=document.createElement('div'); opt.className='ws-opt ws-opt-action'; @@ -5833,7 +5868,7 @@ function toggleWsDropdown(){ closeProfileDropdown(); // close profile dropdown if open loadWorkspaceList().then(data=>{ renderWorkspaceDropdownInto(dd, data.workspaces, S.session?.workspace||S._profileDefaultWorkspace||data.last||''); - dd.classList.add('open'); + _setWorkspaceDropdownOpenState(dd,true); }); } } @@ -5853,7 +5888,7 @@ function toggleComposerWsDropdown(){ if(typeof closeReasoningDropdown==='function') closeReasoningDropdown(); loadWorkspaceList().then(data=>{ renderWorkspaceDropdownInto(dd, data.workspaces, S.session?.workspace||S._profileDefaultWorkspace||data.last||''); - dd.classList.add('open'); + _setWorkspaceDropdownOpenState(dd,true); _positionComposerWsDropdown(); if(chip){ chip.classList.add('active'); @@ -5872,8 +5907,8 @@ function closeWsDropdown(){ const composerDd=$('composerWsDropdown'); const composerChip=$('composerWorkspaceChip'); const mobileAction=$('composerMobileWorkspaceAction'); - if(dd)dd.classList.remove('open'); - if(composerDd)composerDd.classList.remove('open'); + if(dd)_setWorkspaceDropdownOpenState(dd,false); + if(composerDd)_setWorkspaceDropdownOpenState(composerDd,false); if(composerChip){ composerChip.classList.remove('active'); composerChip.setAttribute('aria-expanded','false'); @@ -6325,6 +6360,10 @@ async function switchToWorkspace(path,name){ if(typeof cancelEditMode==='function')cancelEditMode(); if(typeof clearPreview==='function')clearPreview(); } + const composerDd=(typeof $==='function')?$('composerWsDropdown'):null; + const restoreComposerFocusTarget=(composerDd&&composerDd.classList.contains('open')&&typeof _getComposerWorkspaceFocusTarget==='function') + ? _getComposerWorkspaceFocusTarget() + : null; try{ closeWsDropdown(); await api('/api/session/update',{method:'POST',body:JSON.stringify({ @@ -6336,6 +6375,12 @@ async function switchToWorkspace(path,name){ S._profileSwitchWorkspace=null; S._pendingSessionToolsets=null; syncTopbar(); + if( + restoreComposerFocusTarget&& + typeof _shouldRestoreComposerWorkspaceFocus==='function'&& + _shouldRestoreComposerWorkspaceFocus(composerDd)&& + typeof _focusComposerWorkspaceTarget==='function' + ) _focusComposerWorkspaceTarget(restoreComposerFocusTarget); await loadDir('.'); if (_currentPanel === 'memory') await loadMemory(true); showToast(t('workspace_switched_to',name||getWorkspaceFriendlyName(path))); diff --git a/tests/test_issue5671_workspace_switcher_a11y.py b/tests/test_issue5671_workspace_switcher_a11y.py index bc18b430..d27f6fbf 100644 --- a/tests/test_issue5671_workspace_switcher_a11y.py +++ b/tests/test_issue5671_workspace_switcher_a11y.py @@ -47,6 +47,21 @@ def test_composer_workspace_dropdown_keeps_aria_expanded_in_sync(): assert "mobileAction.setAttribute('aria-expanded','false')" in close +def test_closed_composer_workspace_dropdown_is_removed_from_screen_reader_navigation(): + assert 'id="composerWsDropdown" hidden aria-hidden="true" inert' in INDEX_HTML + + helper = _block(PANELS_JS, "function _setWorkspaceDropdownOpenState", "function _getComposerWorkspaceFocusTarget") + assert "dd.hidden=!open" in helper + assert "dd.setAttribute('aria-hidden',open?'false':'true')" in helper + assert "dd.removeAttribute('inert')" in helper + assert "dd.setAttribute('inert','')" in helper + + toggle = _block(PANELS_JS, "function toggleComposerWsDropdown", "function closeWsDropdown") + close = _block(PANELS_JS, "function closeWsDropdown", "document.addEventListener('click'") + assert "_setWorkspaceDropdownOpenState(dd,true)" in toggle + assert "_setWorkspaceDropdownOpenState(composerDd,false)" in close + + def test_composer_workspace_cue_is_transient_not_a_persistent_editor_description(): assert 'id="msg"' in INDEX_HTML assert 'aria-describedby="composerWorkspaceContext"' not in INDEX_HTML @@ -59,6 +74,25 @@ def test_composer_workspace_cue_is_transient_not_a_persistent_editor_description assert "workspace_context_none" not in sync +def test_workspace_selection_from_composer_dropdown_restores_focus_to_opening_trigger_once(): + focus_helpers = _block(PANELS_JS, "function _getComposerWorkspaceFocusTarget", "function _renderWorkspaceAction") + switch = _block(PANELS_JS, "async function switchToWorkspace", "// ── Profile panel") + + assert "const mobileAction=(typeof $==='function')?$('composerMobileWorkspaceAction'):null" in focus_helpers + assert "if(panel&&panel.classList.contains('open')&&mobileAction&&!mobileAction.disabled) return mobileAction" in focus_helpers + assert "return (typeof $==='function')?$('composerWorkspaceChip'):null" in focus_helpers + assert "function _shouldRestoreComposerWorkspaceFocus(dd)" in focus_helpers + assert "return !!(dd&&dd.contains(active))" in focus_helpers + + assert "const restoreComposerFocusTarget=(composerDd&&composerDd.classList.contains('open')&&typeof _getComposerWorkspaceFocusTarget==='function')" in switch + assert "_shouldRestoreComposerWorkspaceFocus(composerDd)" in switch + assert "_focusComposerWorkspaceTarget(restoreComposerFocusTarget)" in switch + assert switch.count("_focusComposerWorkspaceTarget(restoreComposerFocusTarget)") == 1 + assert switch.index("syncTopbar();") < switch.index("_focusComposerWorkspaceTarget(restoreComposerFocusTarget)") < switch.index("await loadDir('.')") + assert "workspace_switched_to" in switch + assert switch.index("_focusComposerWorkspaceTarget(restoreComposerFocusTarget)") < switch.index("workspace_switched_to") + + def test_new_chat_has_screen_reader_only_workspace_announcer(): assert 'id="a11yAnnouncer"' in INDEX_HTML assert 'class="sr-only"' in INDEX_HTML