feat: contain mobile Search focus through Preview (Closes #1390)
This commit is contained in:
1 parent
6e19639b19
commit
4855bc7c8d
8 files changed
+270
-5
No files matched your search
@@ -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()
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user