feat: contain mobile Device Setup focus (Closes #1134)
This commit is contained in:
parent
f3c9e89920
commit
4b3e53f4d0
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user