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)'),
|
mediaQuery: window.matchMedia('(max-width: 600px)'),
|
||||||
schedule: callback => requestAnimationFrame(callback),
|
schedule: callback => requestAnimationFrame(callback),
|
||||||
});
|
});
|
||||||
|
const searchModal = createMobileSearchModal({document});
|
||||||
function closeSearchPreview(navigate = true) {
|
function closeSearchPreview(navigate = true) {
|
||||||
if (searchPreviewReturnKind === 'following') {
|
if (searchPreviewReturnKind === 'following') {
|
||||||
searchPreview.close();
|
searchPreview.close();
|
||||||
|
|
@ -5870,7 +5871,7 @@
|
||||||
qs('#cmd-palette').classList.add('open');
|
qs('#cmd-palette').classList.add('open');
|
||||||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
||||||
renderCommands(qs('#cmd-input').value);
|
renderCommands(qs('#cmd-input').value);
|
||||||
qs('#cmd-input').focus();
|
searchModal.transition(qs('#cmd-palette'), { initialFocus:qs('#cmd-input') });
|
||||||
}
|
}
|
||||||
const next = searchPreview.next;
|
const next = searchPreview.next;
|
||||||
async function openPreviewWorkInMyWork(detail) {
|
async function openPreviewWorkInMyWork(detail) {
|
||||||
|
|
@ -5892,6 +5893,7 @@
|
||||||
mobileSearchViewport.rememberScroll();
|
mobileSearchViewport.rememberScroll();
|
||||||
searchPreviewReturnKind = 'search';
|
searchPreviewReturnKind = 'search';
|
||||||
searchPreview.open(item.result).catch(() => {});
|
searchPreview.open(item.result).catch(() => {});
|
||||||
|
searchModal.transition(qs('#search-preview'), { initialFocus:qs('#close-search-preview') });
|
||||||
taskOverlayHistory.open('search-preview', {
|
taskOverlayHistory.open('search-preview', {
|
||||||
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
|
query:qs('#cmd-input').value, scope:currentSearchScope(), preview:item.result,
|
||||||
});
|
});
|
||||||
|
|
@ -5938,7 +5940,10 @@
|
||||||
mobileSearchViewport.open();
|
mobileSearchViewport.open();
|
||||||
mobileSearchViewport.restoreScroll();
|
mobileSearchViewport.restoreScroll();
|
||||||
qs('#cmd-input').setAttribute('aria-expanded', 'true');
|
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;
|
commandSelection = -1;
|
||||||
renderCommands(qs('#cmd-input').value);
|
renderCommands(qs('#cmd-input').value);
|
||||||
}
|
}
|
||||||
|
|
@ -5957,6 +5962,7 @@
|
||||||
else {
|
else {
|
||||||
searchPreview.close();
|
searchPreview.close();
|
||||||
mobileSearchViewport.close();
|
mobileSearchViewport.close();
|
||||||
|
searchModal.deactivate();
|
||||||
}
|
}
|
||||||
searchPreviewReturnKind = null;
|
searchPreviewReturnKind = null;
|
||||||
}
|
}
|
||||||
|
|
@ -5964,7 +5970,7 @@
|
||||||
qs('#cmd-palette').classList.remove('open');
|
qs('#cmd-palette').classList.remove('open');
|
||||||
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
qs('#cmd-input').setAttribute('aria-expanded', 'false');
|
||||||
mobileSearchViewport.close();
|
mobileSearchViewport.close();
|
||||||
qs('#open-palette').focus();
|
searchModal.deactivate();
|
||||||
}
|
}
|
||||||
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
|
if (previous === 'plan-today-preview' && kind !== 'plan-today-preview') {
|
||||||
if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true });
|
if (addPlanPreviewOnReturn && addPlanPreviewOverride) planTodayPreview.close({ add:true, override:true });
|
||||||
|
|
@ -5992,6 +5998,10 @@
|
||||||
if (detail?.scope) applySearchScope(detail.scope);
|
if (detail?.scope) applySearchScope(detail.scope);
|
||||||
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
|
if (detail?.query !== undefined) qs('#cmd-input').value = detail.query;
|
||||||
searchPreviewReturnKind = 'search';
|
searchPreviewReturnKind = 'search';
|
||||||
|
searchModal.activate(qs('#search-preview'), {
|
||||||
|
opener:document.activeElement,
|
||||||
|
initialFocus:qs('#close-search-preview'),
|
||||||
|
});
|
||||||
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
|
searchPreview.open(detail.preview).catch(() => taskOverlayHistory.close());
|
||||||
}
|
}
|
||||||
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
|
if (kind === 'plan-today' && previous !== 'plan-today' && previous !== 'plan-today-preview') openPlanToday(planTodayTrigger, false);
|
||||||
|
|
|
||||||
|
|
@ -780,7 +780,7 @@
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</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">
|
<div class="cmd-palette-header">
|
||||||
<strong>Search work</strong>
|
<strong>Search work</strong>
|
||||||
<div class="cmd-palette-header-actions">
|
<div class="cmd-palette-header-actions">
|
||||||
|
|
@ -2367,6 +2367,7 @@
|
||||||
<script src="static/device-storage.js"></script>
|
<script src="static/device-storage.js"></script>
|
||||||
<script src="static/mobile-device-setup.js"></script>
|
<script src="static/mobile-device-setup.js"></script>
|
||||||
<script src="static/mobile-search-viewport.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/mobile-composer-viewport.js"></script>
|
||||||
<script src="static/mention-composer.js"></script>
|
<script src="static/mention-composer.js"></script>
|
||||||
<script src="static/push-notifications.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/device-storage.js',
|
||||||
BASE + 'static/mobile-device-setup.js',
|
BASE + 'static/mobile-device-setup.js',
|
||||||
BASE + 'static/mobile-search-viewport.js',
|
BASE + 'static/mobile-search-viewport.js',
|
||||||
|
BASE + 'static/mobile-search-modal.js',
|
||||||
BASE + 'static/mobile-composer-viewport.js',
|
BASE + 'static/mobile-composer-viewport.js',
|
||||||
BASE + 'static/mention-composer.js',
|
BASE + 'static/mention-composer.js',
|
||||||
BASE + 'static/push-notifications.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",
|
"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": (
|
"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/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/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",
|
"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})
|
page.set_viewport_size({"width": 800, "height": 700})
|
||||||
expect(navigation).to_be_hidden()
|
expect(navigation).to_be_hidden()
|
||||||
browser.close()
|
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/device-storage.js",
|
||||||
"/dashboard/static/mobile-device-setup.js",
|
"/dashboard/static/mobile-device-setup.js",
|
||||||
"/dashboard/static/mobile-search-viewport.js",
|
"/dashboard/static/mobile-search-viewport.js",
|
||||||
|
"/dashboard/static/mobile-search-modal.js",
|
||||||
"/dashboard/static/mobile-composer-viewport.js",
|
"/dashboard/static/mobile-composer-viewport.js",
|
||||||
"/dashboard/static/mention-composer.js",
|
"/dashboard/static/mention-composer.js",
|
||||||
"/dashboard/static/push-notifications.js",
|
"/dashboard/static/push-notifications.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user