Merge pull request 'Keep mobile Search and Preview focus-contained through Back navigation' (#1391) from timmy/1390-mobile-search-modal-focus into main
All checks were successful
CI / lint (push) Successful in 3m26s
CI / build-release (push) Successful in 6s
CI / browser-journey (push) Successful in 6m11s
CI / release-candidate (push) Successful in 8s

This commit is contained in:
timmy 2026-08-25 10:20:10 +00:00
commit 2acd9431e0
8 changed files with 270 additions and 5 deletions

View File

@ -5855,6 +5855,7 @@
mediaQuery: window.matchMedia('(max-width: 600px)'),
schedule: callback => requestAnimationFrame(callback),
});
const searchModal = createMobileSearchModal({document});
function closeSearchPreview(navigate = true) {
if (searchPreviewReturnKind === 'following') {
searchPreview.close();
@ -5870,7 +5871,7 @@
qs('#cmd-palette').classList.add('open');
qs('#cmd-input').setAttribute('aria-expanded', 'true');
renderCommands(qs('#cmd-input').value);
qs('#cmd-input').focus();
searchModal.transition(qs('#cmd-palette'), { initialFocus:qs('#cmd-input') });
}
const next = searchPreview.next;
async function openPreviewWorkInMyWork(detail) {
@ -5892,6 +5893,7 @@
mobileSearchViewport.rememberScroll();
searchPreviewReturnKind = 'search';
searchPreview.open(item.result).catch(() => {});
searchModal.transition(qs('#search-preview'), { initialFocus:qs('#close-search-preview') });
taskOverlayHistory.open('search-preview', {
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
});
@ -5938,7 +5940,10 @@
mobileSearchViewport.open();
mobileSearchViewport.restoreScroll();
qs('#cmd-input').setAttribute('aria-expanded', 'true');
qs('#cmd-input').focus();
searchModal.activate(qs('#cmd-palette'), {
opener:document.activeElement,
initialFocus:qs('#cmd-input'),
});
commandSelection = -1;
renderCommands(qs('#cmd-input').value);
}
@ -5957,6 +5962,7 @@
else {
searchPreview.close();
mobileSearchViewport.close();
searchModal.deactivate();
}
searchPreviewReturnKind = null;
}
@ -5964,7 +5970,7 @@
qs('#cmd-palette').classList.remove('open');
qs('#cmd-input').setAttribute('aria-expanded', 'false');
mobileSearchViewport.close();
qs('#open-palette').focus();
searchModal.deactivate();
}
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true });
@ -5992,6 +5998,10 @@
if (detail?.scope) applySearchScope(detail.scope);
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
searchPreviewReturnKind = 'search';
searchModal.activate(qs('#search-preview'), {
opener:document.activeElement,
initialFocus:qs('#close-search-preview'),
});
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
}
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);

View File

@ -780,7 +780,7 @@
</section>
</div>
<div id="cmd-palette" role="dialog" aria-label="Command palette">
<div id="cmd-palette" role="dialog" aria-modal="true" aria-label="Command palette">
<div class="cmd-palette-header">
<strong>Search work</strong>
<div class="cmd-palette-header-actions">
@ -2367,6 +2367,7 @@
<script src="static/device-storage.js"></script>
<script src="static/mobile-device-setup.js"></script>
<script src="static/mobile-search-viewport.js"></script>
<script src="static/mobile-search-modal.js"></script>
<script src="static/mobile-composer-viewport.js"></script>
<script src="static/mention-composer.js"></script>
<script src="static/push-notifications.js"></script>

View File

@ -0,0 +1,83 @@
(function (root, factory) {
const createMobileSearchModal = factory();
if (typeof module !== 'undefined' && module.exports) module.exports = createMobileSearchModal;
else root.createMobileSearchModal = createMobileSearchModal;
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
const FOCUSABLE = [
'a[href]', 'button', 'input', 'select', 'textarea',
'[tabindex]:not([tabindex="-1"])', '[contenteditable="true"]',
].join(',');
return function createMobileSearchModal(options) {
const documentRef = options.document;
const backgrounds = Array.from(options.backgrounds || [
documentRef.querySelector?.('body > header'),
documentRef.querySelector?.('main'),
documentRef.querySelector?.('#mobile-task-dock'),
]).filter(Boolean);
let surface = null;
let opener = null;
let priorInert = null;
function focusable() {
if (!surface || typeof surface.querySelectorAll !== 'function') return [];
return Array.from(surface.querySelectorAll(FOCUSABLE)).filter(element =>
!element.disabled && !element.hidden && element.getAttribute?.('aria-hidden') !== 'true' &&
(typeof element.matches !== 'function' || element.matches(':not([hidden])')) &&
(typeof element.getClientRects !== 'function' || element.getClientRects().length > 0)
);
}
function focus(element) {
if (element?.isConnected !== false && typeof element?.focus === 'function') element.focus();
}
function onKeydown(event) {
if (!surface || event.key !== 'Tab') return;
const controls = focusable();
if (!controls.length) {
event.preventDefault();
return;
}
const first = controls[0];
const last = controls[controls.length - 1];
if (event.shiftKey && documentRef.activeElement === first) {
event.preventDefault();
focus(last);
} else if (!event.shiftKey && documentRef.activeElement === last) {
event.preventDefault();
focus(first);
} else if (!controls.includes(documentRef.activeElement)) {
event.preventDefault();
focus(event.shiftKey ? last : first);
}
}
function transition(nextSurface, transitionOptions = {}) {
surface = nextSurface;
focus(transitionOptions.initialFocus || focusable()[0]);
}
function activate(nextSurface, activateOptions = {}) {
if (!surface) {
opener = activateOptions.opener || documentRef.activeElement || null;
priorInert = backgrounds.map(element => element.inert === true);
backgrounds.forEach(element => { element.inert = true; });
}
transition(nextSurface, activateOptions);
}
function deactivate() {
if (!surface) return;
backgrounds.forEach((element, index) => { element.inert = priorInert[index]; });
surface = null;
priorInert = null;
const restore = opener;
opener = null;
focus(restore);
}
documentRef.addEventListener('keydown', onKeydown);
return { activate, transition, deactivate, current: () => surface };
};
});

View File

@ -283,6 +283,7 @@ const SHELL = [
BASE + 'static/device-storage.js',
BASE + 'static/mobile-device-setup.js',
BASE + 'static/mobile-search-viewport.js',
BASE + 'static/mobile-search-modal.js',
BASE + 'static/mobile-composer-viewport.js',
BASE + 'static/mention-composer.js',
BASE + 'static/push-notifications.js',

View File

@ -39,7 +39,7 @@ FEATURE_SOURCES = {
"static/tomorrow-plan.js", "static/week-calendar.js", "static/week-calendar-import.js", "static/week-plan.js", "static/today-week-reschedule.js", "static/search-week-plan.js", "static/search-batch-plan.js", "static/agenda-session-launcher.js", "static/mobile-plan-today-nav.js",
),
"today-timer": (
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-composer-viewport.js",
"static/mobile-app-badge.js", "static/conversation.js", "static/widgets.js", "static/voice-transcript-store.js", "static/voice-conversation-capture.js", "static/mobile-launch.js", "static/mobile-insights.js", "static/mobile-app-shortcuts.js", "static/mobile-find-work-nav.js", "static/mobile-pull-refresh.js", "static/live-data-status.js", "static/mobile-search-modal.js", "static/mobile-composer-viewport.js",
"static/today-completion.js", "static/card-planning.js", "static/work-detail-position.js", "static/work-route.js", "static/commands.js", "static/saved-searches.js", "static/task-overlay-history.js", "static/mobile-search-preview-nav.js", "static/search-reply-draft-store.js", "static/conversation-reply-draft-store.js", "static/conversation-photo-drafts.js", "static/search-defer.js", "static/mobile-search-viewport.js", "static/agenda-replan.js", "static/agenda-calendar.js", "static/my-work.js", "static/protect-today.js", "static/mobile-today-command-bar.js", "static/mobile-task-dock.js", "static/mobile-first-task.js", "static/mobile-work-entry.js", "static/mobile-queue-launcher.js", "static/mobile-delivery-recovery.js", "static/mobile-start-day.js", "static/update-triage-session.js", "static/update-review-handoff.js", "static/update-triage-launcher.js", "static/update-triage-gesture.js", "static/notification-undo.js", "static/today-timer.js", "static/today-break.js", "static/today-progress.js", "static/today-lock-screen.js", "static/today-session-sync.js", "static/today-recap.js", "static/today-wrap-up.js", "static/today-summary.js", "static/today-handoff.js",
"static/later-work.js", "static/detail-defer.js", "static/later-picker.js", "static/drafts.js", "static/unfiled-captures.js", "static/unfiled-draft-sync.js",
"static/assign-and-start.js", "static/filed-claim.js", "static/queue-today.js", "static/create-and-start.js",

View File

@ -88,3 +88,62 @@ def test_rendered_mobile_search_preview_navigation_preserves_reading_space_and_d
page.set_viewport_size({"width": 800, "height": 700})
expect(navigation).to_be_hidden()
browser.close()
@pytest.mark.parametrize(("width", "height"), [(320, 568), (390, 844)])
def test_rendered_search_to_preview_journey_contains_focus_until_final_close(width, height):
html = re.sub(r'<script src="static/[^"]+"></script>', "", (FRONTEND / "index.html").read_text())
with sync_playwright() as playwright:
browser = playwright.chromium.launch()
page = browser.new_page(viewport={"width": width, "height": height})
page.set_content(html)
page.add_style_tag(path=FRONTEND / "dashboard.css")
page.add_script_tag(path=FRONTEND / "mobile-search-modal.js")
page.evaluate("""() => {
const palette = document.querySelector('#cmd-palette');
const preview = document.querySelector('#search-preview');
palette.classList.add('open');
window.searchModalTest = createMobileSearchModal({document});
window.searchModalTest.activate(palette, {
opener:document.querySelector('[data-mobile-task="search"]'),
initialFocus:document.querySelector('#cmd-input'),
});
preview.append(Object.assign(document.createElement('button'), {id:'dynamic-preview-action', textContent:'Dynamic action'}));
}""")
expect(page.locator("#cmd-input")).to_be_focused()
expect(page.locator("#cmd-palette")).to_have_attribute("aria-modal", "true")
assert page.locator("body > header").evaluate("node => node.inert") is True
assert page.locator("main").evaluate("node => node.inert") is True
assert page.locator("#mobile-task-dock").evaluate("node => node.inert") is True
page.evaluate("""() => {
document.querySelector('#cmd-palette').classList.remove('open');
document.querySelector('#search-preview').classList.add('open');
searchModalTest.transition(document.querySelector('#search-preview'), {
initialFocus:document.querySelector('#close-search-preview'),
});
document.querySelector('#dynamic-preview-action').focus();
}""")
page.keyboard.press("Tab")
expect(page.locator("#close-search-preview")).to_be_focused()
assert page.locator("main").evaluate("node => node.inert") is True
page.evaluate("""() => {
document.querySelector('#search-preview').classList.remove('open');
document.querySelector('#cmd-palette').classList.add('open');
searchModalTest.transition(document.querySelector('#cmd-palette'), {
initialFocus:document.querySelector('#cmd-input'),
});
}""")
expect(page.locator("#cmd-input")).to_be_focused()
assert page.locator("main").evaluate("node => node.inert") is True
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
page.evaluate("searchModalTest.deactivate()")
expect(page.locator('[data-mobile-task="search"]')).to_be_focused()
assert page.locator("body > header").evaluate("node => node.inert") is False
assert page.locator("main").evaluate("node => node.inert") is False
assert page.locator("#mobile-task-dock").evaluate("node => node.inert") is False
browser.close()

View File

@ -0,0 +1,110 @@
import json
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
CONTROLLER = ROOT / "frontend" / "mobile-search-modal.js"
def run_node(script: str) -> dict:
completed = subprocess.run(["node", "-e", script], capture_output=True, text=True)
assert completed.returncode == 0, completed.stderr
return json.loads(completed.stdout)
def test_search_preview_journey_keeps_background_inert_and_restores_prior_state():
script = f"""
const createModal = require({json.dumps(str(CONTROLLER))});
const listeners = {{}};
const element = (name) => ({{
name, inert:false, isConnected:true, hidden:false, disabled:false, focuses:0,
focus() {{ this.focuses += 1; }},
matches() {{ return true; }},
}});
const header=element('header'), main=element('main'), dock=element('dock'); dock.inert=true;
const palette=element('palette'), preview=element('preview');
const input=element('input'), close=element('close'), opener=element('opener');
palette.querySelectorAll=()=>[input]; preview.querySelectorAll=()=>[close];
const controller=createModal({{
document:{{addEventListener(name,fn){{listeners[name]=fn;}}}},
backgrounds:[header,main,dock],
}});
controller.activate(palette, {{opener, initialFocus:input}});
const opened=[header.inert,main.inert,dock.inert,input.focuses];
controller.transition(preview, {{initialFocus:close}});
const previewed=[header.inert,main.inert,dock.inert,close.focuses,controller.current()===preview];
controller.transition(palette, {{initialFocus:input}});
const returned=[header.inert,main.inert,dock.inert,input.focuses];
controller.deactivate();
process.stdout.write(JSON.stringify({{opened,previewed,returned,restored:[header.inert,main.inert,dock.inert,opener.focuses]}}));
"""
assert run_node(script) == {
"opened": [True, True, True, 1],
"previewed": [True, True, True, 1, True],
"returned": [True, True, True, 2],
"restored": [False, False, True, 1],
}
def test_tab_and_shift_tab_wrap_dynamic_visible_controls_on_active_surface():
script = f"""
const createModal = require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const element=(name)=>({{name,inert:false,isConnected:true,hidden:false,disabled:false,focuses:0,
focus(){{this.focuses+=1;}},matches(selector){{return !this.hidden;}}}});
const surface=element('surface'), first=element('first'), middle=element('middle'), last=element('last');
const hidden=element('hidden'); hidden.hidden=true; const disabled=element('disabled'); disabled.disabled=true;
surface.querySelectorAll=()=>[first,hidden,middle,disabled,last];
const document={{activeElement:last,addEventListener(name,fn){{listeners[name]=fn;}}}};
const controller=createModal({{document,backgrounds:[]}});
controller.activate(surface,{{initialFocus:first}});
let prevented=0;
document.activeElement=last; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
document.activeElement=first; listeners.keydown({{key:'Tab',shiftKey:true,preventDefault(){{prevented+=1;}}}});
// A newly rendered enabled control becomes the last stop without remounting.
const dynamic=element('dynamic'); surface.querySelectorAll=()=>[first,middle,last,dynamic];
document.activeElement=dynamic; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
process.stdout.write(JSON.stringify({{first:first.focuses,last:last.focuses,prevented}}));
"""
assert run_node(script) == {"first": 3, "last": 1, "prevented": 3}
def test_disconnected_opener_is_not_focused_on_final_close():
script = f"""
const createModal=require({json.dumps(str(CONTROLLER))});
const surface={{querySelectorAll(){{return[];}}}};
const opener={{isConnected:true,focuses:0,focus(){{this.focuses+=1;}}}};
const document={{addEventListener(){{}}}};
const controller=createModal({{document,backgrounds:[]}});
controller.activate(surface,{{opener}}); opener.isConnected=false; controller.deactivate();
process.stdout.write(JSON.stringify({{focuses:opener.focuses}}));
"""
assert run_node(script) == {"focuses": 0}
def test_controls_inside_collapsed_sections_are_excluded_from_tab_order():
script = f"""
const createModal=require({json.dumps(str(CONTROLLER))});
const listeners={{}};
const visible={{disabled:false,hidden:false,isConnected:true,focuses:0,matches(){{return true;}},getClientRects(){{return [{{}}];}},focus(){{this.focuses+=1;}}}};
const collapsed={{...visible,getClientRects(){{return [];}}}};
const surface={{querySelectorAll(){{return [visible,collapsed];}}}};
const document={{activeElement:visible,addEventListener(name,fn){{listeners[name]=fn;}}}};
const controller=createModal({{document,backgrounds:[]}}); controller.activate(surface);
let prevented=0; listeners.keydown({{key:'Tab',shiftKey:false,preventDefault(){{prevented+=1;}}}});
process.stdout.write(JSON.stringify({{prevented,focuses:visible.focuses}}));
"""
assert run_node(script) == {"prevented": 1, "focuses": 2}
def test_search_modal_is_packaged_with_accessible_semantics():
html = (ROOT / "frontend" / "index.html").read_text()
dashboard = (ROOT / "frontend" / "dashboard.js").read_text()
bundle = (ROOT / "src" / "frontend_bundle.py").read_text()
assert 'id="cmd-palette" role="dialog" aria-modal="true"' in html
assert '<script src="static/mobile-search-modal.js"></script>' in html
assert '"static/mobile-search-modal.js"' in bundle
assert "createMobileSearchModal({" in dashboard
assert "searchModal.transition(qs('#search-preview')" in dashboard

View File

@ -1505,6 +1505,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/static/device-storage.js",
"/dashboard/static/mobile-device-setup.js",
"/dashboard/static/mobile-search-viewport.js",
"/dashboard/static/mobile-search-modal.js",
"/dashboard/static/mobile-composer-viewport.js",
"/dashboard/static/mention-composer.js",
"/dashboard/static/push-notifications.js",