Contain focus inside mobile Security Center #1137
|
|
@ -19,6 +19,31 @@
|
|||
section, root.document.getElementById(`security-${section}-section`),
|
||||
]));
|
||||
let activityCursor = null;
|
||||
let backgroundInert = null;
|
||||
|
||||
const backgroundElements = ['header', 'main', '#mobile-task-dock']
|
||||
.map(selector => root.document.querySelector?.(selector))
|
||||
.filter(Boolean);
|
||||
const focusableControls = () => [...(devicesSheet?.querySelectorAll?.(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
) || [])].filter(control => !control.hidden && !control.disabled);
|
||||
const containBackground = () => {
|
||||
if (!backgroundInert) {
|
||||
backgroundInert = new Map(backgroundElements.map(element => [element, element.inert]));
|
||||
}
|
||||
backgroundInert.forEach((_wasInert, element) => { element.inert = true; });
|
||||
};
|
||||
const releaseBackground = () => {
|
||||
if (!backgroundInert) return;
|
||||
backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; });
|
||||
backgroundInert = null;
|
||||
};
|
||||
const finishClose = () => {
|
||||
devicesSheet.hidden = true;
|
||||
releaseBackground();
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const navigate = (section, { history = true } = {}) => {
|
||||
if (!sections[section]) section = 'activity';
|
||||
|
|
@ -43,16 +68,41 @@
|
|||
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
});
|
||||
root.addEventListener?.('popstate', event => {
|
||||
const section = event.state?.stackchainSecuritySection;
|
||||
const handleRouteChange = section => {
|
||||
if (section && !devicesSheet.hidden) {
|
||||
containBackground();
|
||||
navigate(section, { history: false });
|
||||
return;
|
||||
}
|
||||
if (!devicesSheet.hidden) {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
onClose();
|
||||
finishClose();
|
||||
}
|
||||
};
|
||||
root.addEventListener?.('popstate', event => {
|
||||
handleRouteChange(event.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.addEventListener?.('hashchange', () => {
|
||||
handleRouteChange(root.history?.state?.stackchainSecuritySection);
|
||||
});
|
||||
root.document.addEventListener?.('keydown', event => {
|
||||
if (devicesSheet.hidden) return;
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||
else finishClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') 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();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -225,6 +275,7 @@
|
|||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
onOpen();
|
||||
containBackground();
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||
|
|
|
|||
|
|
@ -170,6 +170,25 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
page.locator("#active-devices").click()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_visible()
|
||||
expect(page.locator("#security-activity-section")).to_be_visible()
|
||||
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-active-devices")).to_be_focused()
|
||||
expect(page.locator("#active-devices-status")).to_contain_text("active device")
|
||||
expect(page.locator("#enrolled-passkeys-status")).not_to_contain_text("Loading")
|
||||
page.keyboard.press("Shift+Tab")
|
||||
assert page.evaluate(
|
||||
"""
|
||||
() => {
|
||||
const controls = [...document.querySelector('#active-devices-sheet').querySelectorAll(
|
||||
'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])'
|
||||
)].filter(control => !control.hidden);
|
||||
return document.activeElement === controls.at(-1);
|
||||
}
|
||||
"""
|
||||
)
|
||||
page.keyboard.press("Tab")
|
||||
expect(page.locator("#close-active-devices")).to_be_focused()
|
||||
security_nav = page.locator(".security-section-nav")
|
||||
expect(security_nav).to_be_visible()
|
||||
for control in security_nav.locator("button").all():
|
||||
|
|
@ -179,9 +198,15 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
|||
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
|
||||
page.go_back()
|
||||
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
|
||||
expect(page.locator("body > header")).to_have_attribute("inert", "")
|
||||
expect(page.locator("main")).to_have_attribute("inert", "")
|
||||
expect(dock).to_have_attribute("inert", "")
|
||||
page.go_back()
|
||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||
expect(page.locator("#active-devices")).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", "")
|
||||
|
||||
assert len(workspace_requests) == 1, workspace_requests
|
||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||
|
|
|
|||
|
|
@ -304,3 +304,139 @@ const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecu
|
|||
)
|
||||
|
||||
assert json.loads(completed.stdout) == {"backs": 1}
|
||||
|
||||
|
||||
def test_security_center_contains_focus_and_restores_prior_background_state_on_final_back():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const listeners={{}};
|
||||
const element=(id='', inert=false)=>({{
|
||||
id, inert, hidden:false, disabled:false, textContent:'', children:[], attributes:{{}},
|
||||
addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}},
|
||||
focus(){{focused=this;}}, replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}},
|
||||
setAttribute(name,value){{this.attributes[name]=value;}}, removeAttribute(name){{delete this.attributes[name];}},
|
||||
scrollIntoView(){{}},
|
||||
}});
|
||||
let focused=null;
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices',
|
||||
'security-section-activity','security-section-devices','security-section-passkeys',
|
||||
'security-activity-section','security-devices-section','security-passkeys-section'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element(id)]));
|
||||
ids['active-devices-sheet'].hidden=true;
|
||||
const header=element('header');
|
||||
const main=element('main', true);
|
||||
const dock=element('mobile-task-dock');
|
||||
const dynamicAction=element('revoke-lost-phone');
|
||||
const hiddenAction=element('hidden-action'); hiddenAction.hidden=true;
|
||||
const disabledAction=element('disabled-action'); disabledAction.disabled=true;
|
||||
ids['active-devices-sheet'].querySelectorAll=()=>[
|
||||
ids['close-active-devices'], dynamicAction, hiddenAction, disabledAction,
|
||||
];
|
||||
const documentListeners={{}};
|
||||
const root={{
|
||||
document:{{
|
||||
getElementById:id=>ids[id]||null, createElement:()=>element(),
|
||||
querySelector:selector=>({{'header':header,'main':main,'#mobile-task-dock':dock}})[selector]||null,
|
||||
addEventListener:(type,listener)=>documentListeners[type]=listener,
|
||||
}},
|
||||
history:{{state:null,pushState(state){{this.state=state;}}}},
|
||||
location:{{pathname:'/dashboard/',search:''}},
|
||||
addEventListener:(type,listener)=>listeners[type]=listener,
|
||||
}};
|
||||
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
|
||||
(async()=>{{
|
||||
const controller=attachSecurityCenter({{root,boundary}});
|
||||
await controller.open();
|
||||
const opened={{inert:[header.inert,main.inert,dock.inert],focused:focused?.id}};
|
||||
let shiftPrevented=false;
|
||||
documentListeners.keydown({{key:'Tab',shiftKey:true,target:ids['close-active-devices'],preventDefault(){{shiftPrevented=true;}}}});
|
||||
const afterShift=focused?.id;
|
||||
let tabPrevented=false;
|
||||
documentListeners.keydown({{key:'Tab',shiftKey:false,target:dynamicAction,preventDefault(){{tabPrevented=true;}}}});
|
||||
const afterTab=focused?.id;
|
||||
controller.navigate('devices');
|
||||
header.inert=false; // An earlier popstate listener released its own overlay background.
|
||||
listeners.popstate({{state:{{stackchainSecuritySection:'activity'}}}});
|
||||
header.inert=false; // That listener also reacts to the resulting hashchange.
|
||||
root.history.state={{stackchainSecuritySection:'activity'}};
|
||||
listeners.hashchange({{}});
|
||||
const sectionBack={{hidden:ids['active-devices-sheet'].hidden,inert:[header.inert,main.inert,dock.inert]}};
|
||||
root.history.state=null;
|
||||
listeners.popstate({{state:null}});
|
||||
console.log(JSON.stringify({{
|
||||
opened,shiftPrevented,afterShift,tabPrevented,afterTab,sectionBack,
|
||||
closed:{{hidden:ids['active-devices-sheet'].hidden,inert:[header.inert,main.inert,dock.inert],focused:focused?.id}},
|
||||
}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"opened": {"inert": [True, True, True], "focused": "close-active-devices"},
|
||||
"shiftPrevented": True,
|
||||
"afterShift": "revoke-lost-phone",
|
||||
"tabPrevented": True,
|
||||
"afterTab": "close-active-devices",
|
||||
"sectionBack": {"hidden": False, "inert": [True, True, True]},
|
||||
"closed": {
|
||||
"hidden": True,
|
||||
"inert": [False, True, False],
|
||||
"focused": "active-devices",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def test_escape_closes_security_center_through_history_and_releases_background():
|
||||
harness = f"""
|
||||
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||
const windowListeners={{}};
|
||||
const documentListeners={{}};
|
||||
const element=(id='')=>({{
|
||||
id,inert:false,hidden:false,disabled:false,textContent:'',children:[],attributes:{{}},
|
||||
addEventListener(){{}},focus(){{focused=this.id;}},replaceChildren(){{this.children=[];}},
|
||||
append(...values){{this.children.push(...values);}},setAttribute(){{}},removeAttribute(){{}},scrollIntoView(){{}},
|
||||
}});
|
||||
let focused='';
|
||||
const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status',
|
||||
'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list',
|
||||
'security-activity-status','load-more-security-activity','close-active-devices'];
|
||||
const ids=Object.fromEntries(names.map(id=>[id,element(id)]));
|
||||
ids['active-devices-sheet'].hidden=true;
|
||||
ids['active-devices-sheet'].querySelectorAll=()=>[ids['close-active-devices']];
|
||||
const header=element('header');
|
||||
let backs=0;
|
||||
const root={{
|
||||
document:{{
|
||||
getElementById:id=>ids[id]||null,createElement:()=>element(),
|
||||
querySelector:selector=>selector==='header'?header:null,
|
||||
addEventListener:(type,listener)=>documentListeners[type]=listener,
|
||||
}},
|
||||
history:{{state:null,pushState(state){{this.state=state;}},back(){{backs++;}}}},
|
||||
location:{{pathname:'/dashboard/',search:''}},
|
||||
addEventListener:(type,listener)=>windowListeners[type]=listener,
|
||||
}};
|
||||
const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}};
|
||||
(async()=>{{
|
||||
await attachSecurityCenter({{root,boundary}}).open();
|
||||
let prevented=false;
|
||||
documentListeners.keydown({{key:'Escape',preventDefault(){{prevented=true;}}}});
|
||||
const requested={{backs,prevented,hidden:ids['active-devices-sheet'].hidden,inert:header.inert}};
|
||||
windowListeners.popstate({{state:null}});
|
||||
console.log(JSON.stringify({{requested,closed:{{hidden:ids['active-devices-sheet'].hidden,inert:header.inert,focused}}}}));
|
||||
}})().catch(error=>{{console.error(error);process.exit(1);}});
|
||||
"""
|
||||
completed = subprocess.run(
|
||||
["node", "-e", harness], capture_output=True, text=True
|
||||
)
|
||||
|
||||
assert completed.returncode == 0, completed.stderr
|
||||
assert json.loads(completed.stdout) == {
|
||||
"requested": {"backs": 1, "prevented": True, "hidden": False, "inert": True},
|
||||
"closed": {"hidden": True, "inert": False, "focused": "active-devices"},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user