feat: contain Security Center focus (Closes #1136)
All checks were successful
CI / lint (pull_request) Successful in 2m55s
CI / build-release (pull_request) Successful in 6s
CI / browser-journey (pull_request) Successful in 3m22s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy 2026-08-19 15:29:04 +00:00
parent 61b332e814
commit 931c82361b
3 changed files with 217 additions and 5 deletions

View File

@ -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' });

View File

@ -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")

View File

@ -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"},
}