From 4b3e53f4d03cd2dc7f23b65b0934f972edaa86a7 Mon Sep 17 00:00:00 2001 From: timmy Date: Wed, 19 Aug 2026 14:30:37 +0000 Subject: [PATCH] feat: contain mobile Device Setup focus (Closes #1134) --- frontend/mobile-device-setup.js | 65 +++++++++++++++++- .../e2e/test_mobile_home_bootstrap_release.py | 14 +++- tests/test_mobile_device_setup.py | 67 ++++++++++++++++++- 3 files changed, 142 insertions(+), 4 deletions(-) diff --git a/frontend/mobile-device-setup.js b/frontend/mobile-device-setup.js index 6b8d33e..fefdab8 100644 --- a/frontend/mobile-device-setup.js +++ b/frontend/mobile-device-setup.js @@ -13,6 +13,26 @@ ]; let trigger = options.launcher; let ownsDetour = false; + let backgroundInert = null; + let pendingResume = true; + + function focusableControls() { + return [...options.sheet.querySelectorAll( + 'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])' + )].filter(control => !control.hidden); + } + + function containBackground() { + if (backgroundInert) return; + backgroundInert = new Map((options.backgroundElements || []).map(element => [element, element.inert])); + backgroundInert.forEach((_wasInert, element) => { element.inert = true; }); + } + + function releaseBackground() { + if (!backgroundInert) return; + backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; }); + backgroundInert = null; + } function readinessCounts(readiness) { const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length; @@ -57,17 +77,31 @@ ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup'; } await render(); + containBackground(); options.sheet.hidden = false; + if (options.history && !options.history.state?.deviceSetup) { + options.history.pushState({...options.history.state, deviceSetup:true}, ''); + } options.closeButton.focus(); } - function close(resume = true) { + function finishClose(resume = true) { options.sheet.hidden = true; + releaseBackground(); if (resume && ownsDetour) options.timerView?.finishDetour?.(); ownsDetour = false; trigger?.focus?.(); } + function close(resume = true) { + if (options.history?.state?.deviceSetup) { + pendingResume = resume; + options.history.back(); + return; + } + finishClose(resume); + } + async function start() { options.launcher.addEventListener('click', open); options.promptLauncher?.addEventListener('click', open); @@ -83,7 +117,28 @@ if (event.target === options.sheet) close(); }); options.escapeTarget.addEventListener('keydown', event => { - if (event.key === 'Escape' && !options.sheet.hidden) close(); + if (event.key === 'Escape' && !options.sheet.hidden) { + event.preventDefault?.(); + close(); + } + if (event.key !== 'Tab' || options.sheet.hidden) return; + const controls = focusableControls(); + if (!controls.length) return; + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && event.target === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && event.target === last) { + event.preventDefault(); + first.focus(); + } + }); + options.historyTarget?.addEventListener('popstate', event => { + if (!options.sheet.hidden && !event.state?.deviceSetup) { + finishClose(pendingResume); + pendingResume = true; + } }); steps.forEach(([_name, button, _status, action]) => { button.addEventListener('click', async () => { @@ -106,6 +161,7 @@ if (typeof module === 'object' && module.exports) { function mountMobileDeviceSetup(options) { const qs = selector => options.document.querySelector(selector); + const window = options.window || options.document.defaultView; return createMobileDeviceSetup({ launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'), sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'), @@ -123,6 +179,11 @@ function mountMobileDeviceSetup(options) { timerView:options.timerView, isMobile:options.isMobile || (() => innerWidth <= 600), escapeTarget:options.document, + history:window?.history, + historyTarget:window, + backgroundElements:[ + qs('header'), qs('main'), qs('#mobile-task-dock'), + ].filter(Boolean), getReadiness:() => ({ install:options.installApp.state(), offline:!options.offlineAvailable() diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 1f76acc..9c64683 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -148,12 +148,24 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( expect(page.locator("#device-setup-sheet")).to_be_visible() expect(page.locator("#device-storage-heading")).to_have_text("Private device storage") expect(page.locator("#device-storage-detail")).to_contain_text("private work records") + expect(page.locator("body > header")).to_have_attribute("inert", "") + expect(page.locator("main")).to_have_attribute("inert", "") + expect(dock).to_have_attribute("inert", "") + expect(page.locator("#close-device-setup")).to_be_focused() + page.keyboard.press("Shift+Tab") + expect(page.locator("#clear-private-device-data")).to_be_focused() + page.keyboard.press("Tab") + expect(page.locator("#close-device-setup")).to_be_focused() for selector in ("#clear-device-caches", "#clear-private-device-data"): bounds = page.locator(selector).bounding_box() assert bounds and bounds["height"] >= 44 assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") - page.locator("#close-device-setup").click() + page.go_back() expect(page.locator("#device-setup-sheet")).to_be_hidden() + expect(page.locator("#open-device-setup")).to_be_focused() + expect(page.locator("body > header")).not_to_have_attribute("inert", "") + expect(page.locator("main")).not_to_have_attribute("inert", "") + expect(dock).not_to_have_attribute("inert", "") page.locator("#active-devices").click() expect(page.locator("#active-devices-sheet")).to_be_visible() diff --git a/tests/test_mobile_device_setup.py b/tests/test_mobile_device_setup.py index 8d8b344..a4fdbd5 100644 --- a/tests/test_mobile_device_setup.py +++ b/tests/test_mobile_device_setup.py @@ -10,7 +10,7 @@ def run_scenario(script: str) -> dict: harness = r""" const createMobileDeviceSetup = require(__MODULE__); class FakeTarget { - constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; } + constructor() { this.listeners = {}; this.hidden = false; this.disabled = false; this.textContent = ''; this.inert = false; } addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); } async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); } focus() { state.focused = this; } @@ -39,6 +39,15 @@ const promptSummary = new FakeTarget(); const promptLauncher = new FakeTarget(); const promptDismiss = new FakeTarget(); const returnButton = new FakeTarget(); +const backgroundHeader = new FakeTarget(); +const backgroundMain = new FakeTarget(); backgroundMain.inert = true; +sheet.querySelectorAll = () => [closeButton, installButton, offlineButton, returnButton]; +const historyTarget = new FakeTarget(); +const history = { + state:{section:'home'}, + pushState(state) { this.state = state; }, + back() { this.state = {section:'home'}; historyTarget.dispatch('popstate', {state:this.state}); }, +}; let now = 1000; const promptStorage = { values:{}, @@ -57,6 +66,8 @@ const setup = createMobileDeviceSetup({ installStatus, offlineStatus, protectionStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget, promptCard, promptSummary, promptLauncher, promptDismiss, returnButton, isMobile:() => true, + backgroundElements:[backgroundHeader, backgroundMain], + history, historyTarget, timerView:{ beginDetour:reason => { state.events.push('pause'); state.beginCalls.push(reason); return {reason}; }, finishDetour:() => { state.events.push('resume'); state.finishCalls += 1; }, @@ -206,6 +217,60 @@ process.stdout.write(JSON.stringify({ assert result == {"hidden": True, "launcherFocused": True} +def test_open_setup_contains_focus_and_restores_background_inert_state(): + result = run_scenario(""" +await launcher.dispatch('click', {currentTarget:launcher}); +state.focused = returnButton; +let preventedForward = false; +await escapeTarget.dispatch('keydown', {key:'Tab', target:returnButton, preventDefault(){preventedForward=true;}}); +const forwardFocus = state.focused === closeButton; +state.focused = closeButton; +let preventedReverse = false; +await escapeTarget.dispatch('keydown', {key:'Tab', shiftKey:true, target:closeButton, preventDefault(){preventedReverse=true;}}); +const reverseFocus = state.focused === returnButton; +const inertWhileOpen = [backgroundHeader.inert, backgroundMain.inert]; +await closeButton.dispatch('click'); +process.stdout.write(JSON.stringify({ + preventedForward, forwardFocus, preventedReverse, reverseFocus, + inertWhileOpen, + inertAfterClose:[backgroundHeader.inert, backgroundMain.inert], + launcherFocused:state.focused === launcher, +})); +""") + + assert result == { + "preventedForward": True, + "forwardFocus": True, + "preventedReverse": True, + "reverseFocus": True, + "inertWhileOpen": [True, True], + "inertAfterClose": [False, True], + "launcherFocused": True, + } + + +def test_browser_back_closes_setup_and_restores_launcher_focus(): + result = run_scenario(""" +await launcher.dispatch('click', {currentTarget:launcher}); +const marked = history.state.deviceSetup === true; +history.back(); +await new Promise(resolve => setImmediate(resolve)); +process.stdout.write(JSON.stringify({ + marked, + hidden:sheet.hidden, + backgroundReleased:backgroundHeader.inert === false, + launcherFocused:state.focused === launcher, +})); +""") + + assert result == { + "marked": True, + "hidden": True, + "backgroundReleased": True, + "launcherFocused": True, + } + + def test_mobile_setup_pauses_before_readiness_and_each_exit_resumes_once(): result = run_scenario(""" state.events = []; -- 2.43.0