feat: contain mobile Search focus through Preview (Closes #1390)
This commit is contained in:
parent
6e19639b19
commit
4855bc7c8d
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
83
frontend/mobile-search-modal.js
Normal file
83
frontend/mobile-search-modal.js
Normal 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 };
|
||||
};
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
110
tests/test_mobile_search_modal.py
Normal file
110
tests/test_mobile_search_modal.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user