feat: contain mobile Device Setup focus (Closes #1134)
All checks were successful
CI / lint (pull_request) Successful in 2m54s
CI / build-release (pull_request) Successful in 7s
CI / browser-journey (pull_request) Successful in 3m24s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-19 14:30:37 +00:00
parent f3c9e89920
commit 4b3e53f4d0
3 changed files with 142 additions and 4 deletions

View File

@ -13,6 +13,26 @@
]; ];
let trigger = options.launcher; let trigger = options.launcher;
let ownsDetour = false; 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) { function readinessCounts(readiness) {
const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length; const available = steps.filter(([name]) => readiness[name].state !== 'unavailable').length;
@ -57,17 +77,31 @@
ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup'; ownsDetour = options.timerView?.beginDetour?.('device-setup')?.reason === 'device-setup';
} }
await render(); await render();
containBackground();
options.sheet.hidden = false; options.sheet.hidden = false;
if (options.history && !options.history.state?.deviceSetup) {
options.history.pushState({...options.history.state, deviceSetup:true}, '');
}
options.closeButton.focus(); options.closeButton.focus();
} }
function close(resume = true) { function finishClose(resume = true) {
options.sheet.hidden = true; options.sheet.hidden = true;
releaseBackground();
if (resume && ownsDetour) options.timerView?.finishDetour?.(); if (resume && ownsDetour) options.timerView?.finishDetour?.();
ownsDetour = false; ownsDetour = false;
trigger?.focus?.(); trigger?.focus?.();
} }
function close(resume = true) {
if (options.history?.state?.deviceSetup) {
pendingResume = resume;
options.history.back();
return;
}
finishClose(resume);
}
async function start() { async function start() {
options.launcher.addEventListener('click', open); options.launcher.addEventListener('click', open);
options.promptLauncher?.addEventListener('click', open); options.promptLauncher?.addEventListener('click', open);
@ -83,7 +117,28 @@
if (event.target === options.sheet) close(); if (event.target === options.sheet) close();
}); });
options.escapeTarget.addEventListener('keydown', event => { 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]) => { steps.forEach(([_name, button, _status, action]) => {
button.addEventListener('click', async () => { button.addEventListener('click', async () => {
@ -106,6 +161,7 @@ if (typeof module === 'object' && module.exports) {
function mountMobileDeviceSetup(options) { function mountMobileDeviceSetup(options) {
const qs = selector => options.document.querySelector(selector); const qs = selector => options.document.querySelector(selector);
const window = options.window || options.document.defaultView;
return createMobileDeviceSetup({ return createMobileDeviceSetup({
launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'), launcher:qs('#open-device-setup'), closeButton:qs('#close-device-setup'),
sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'), sheet:qs('#device-setup-sheet'), installButton:qs('#device-setup-install'),
@ -123,6 +179,11 @@ function mountMobileDeviceSetup(options) {
timerView:options.timerView, timerView:options.timerView,
isMobile:options.isMobile || (() => innerWidth <= 600), isMobile:options.isMobile || (() => innerWidth <= 600),
escapeTarget:options.document, escapeTarget:options.document,
history:window?.history,
historyTarget:window,
backgroundElements:[
qs('header'), qs('main'), qs('#mobile-task-dock'),
].filter(Boolean),
getReadiness:() => ({ getReadiness:() => ({
install:options.installApp.state(), install:options.installApp.state(),
offline:!options.offlineAvailable() offline:!options.offlineAvailable()

View File

@ -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-setup-sheet")).to_be_visible()
expect(page.locator("#device-storage-heading")).to_have_text("Private device storage") 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("#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"): for selector in ("#clear-device-caches", "#clear-private-device-data"):
bounds = page.locator(selector).bounding_box() bounds = page.locator(selector).bounding_box()
assert bounds and bounds["height"] >= 44 assert bounds and bounds["height"] >= 44
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") 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("#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() page.locator("#active-devices").click()
expect(page.locator("#active-devices-sheet")).to_be_visible() expect(page.locator("#active-devices-sheet")).to_be_visible()

View File

@ -10,7 +10,7 @@ def run_scenario(script: str) -> dict:
harness = r""" harness = r"""
const createMobileDeviceSetup = require(__MODULE__); const createMobileDeviceSetup = require(__MODULE__);
class FakeTarget { 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); } addEventListener(name, callback) { (this.listeners[name] ||= []).push(callback); }
async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); } async dispatch(name, event = {}) { for (const callback of this.listeners[name] || []) await callback(event); }
focus() { state.focused = this; } focus() { state.focused = this; }
@ -39,6 +39,15 @@ const promptSummary = new FakeTarget();
const promptLauncher = new FakeTarget(); const promptLauncher = new FakeTarget();
const promptDismiss = new FakeTarget(); const promptDismiss = new FakeTarget();
const returnButton = 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; let now = 1000;
const promptStorage = { const promptStorage = {
values:{}, values:{},
@ -57,6 +66,8 @@ const setup = createMobileDeviceSetup({
installStatus, offlineStatus, protectionStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget, installStatus, offlineStatus, protectionStatus, pushStatus, deadlineStatus, readyStatus, escapeTarget,
promptCard, promptSummary, promptLauncher, promptDismiss, promptCard, promptSummary, promptLauncher, promptDismiss,
returnButton, isMobile:() => true, returnButton, isMobile:() => true,
backgroundElements:[backgroundHeader, backgroundMain],
history, historyTarget,
timerView:{ timerView:{
beginDetour:reason => { state.events.push('pause'); state.beginCalls.push(reason); return {reason}; }, beginDetour:reason => { state.events.push('pause'); state.beginCalls.push(reason); return {reason}; },
finishDetour:() => { state.events.push('resume'); state.finishCalls += 1; }, finishDetour:() => { state.events.push('resume'); state.finishCalls += 1; },
@ -206,6 +217,60 @@ process.stdout.write(JSON.stringify({
assert result == {"hidden": True, "launcherFocused": True} 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(): def test_mobile_setup_pauses_before_readiness_and_each_exit_resumes_once():
result = run_scenario(""" result = run_scenario("""
state.events = []; state.events = [];