_createDialog (packages/stx/src/signals.ts:6557) got a real focus trap, focus restore and listener cleanup in #1875, and that fix holds up — I re-verified the trap, the restore and the closed guard on current main. What follows is what #1875 did not cover, found while preparing to migrate an app's seven native confirm() calls onto stxConfirm.
All six verified in a browser against the runtime generated from main (a198835, 0.2.343) via generateSignalsRuntime(), on a 3000px-tall page.
1. No scroll lock: the page scrolls behind an aria-modal dialog
window.scrollTo(0, 0)
const p = window.stxConfirm('Delete server web-01?', { type: 'error', title: 'Delete server' })
await new Promise(r => setTimeout(r, 250))
getComputedStyle(document.body).overflow // "visible"
getComputedStyle(document.documentElement).overflow // "visible"
window.scrollTo(0, 800)
window.scrollY // 800 <-- moved, dialog still openThe backdrop is position:fixed;inset:0, so it stays put while the content behind it scrolls away underneath. Nothing in _createDialog touches overflow on body or documentElement.
2. aria-modal="true" is asserted but the background is never hidden from AT
document.getElementById('behind').closest('[inert]') // null
[...document.body.children]
.filter(c => !c.id?.startsWith('stx-dialog-'))
.map(c => c.getAttribute('aria-hidden')) // [null, null, null]The keyboard trap means Tab cannot leave, which is the important half. But aria-modal support for the virtual cursor is uneven, and the usual belt-and-braces (inert, or aria-hidden on the siblings) is absent, so a screen-reader user can still browse the page behind the dialog.
3. Initial focus always lands on the confirm button, including when it is the destructive one
signals.ts:6703 is an unconditional okBtn.focus().
window.stxConfirm('Delete server web-01? This cannot be undone.',
{ type: 'error', title: 'Delete server', confirmText: 'Delete', cancelText: 'Cancel' })
// document.activeElement.textContent -> "Delete"
// getComputedStyle(document.activeElement).backgroundColor -> "rgb(220, 38, 38)"type: 'error' already paints that button red, so the API knows the action is destructive, and still hands it the keyboard. Any stray Enter or Space confirms the delete. The options _createDialog reads are exactly type, title, confirmText, cancelText, dark - there is no way to ask for Cancel instead.
A defaultFocus: 'cancel' | 'confirm' option would cover it, or simply defaulting to Cancel whenever isConfirm && type === 'error'.
4. One Escape resolves every open dialog, not just the topmost
let a = null, b = null
const A = stxConfirm('Cancel your subscription?', { title: 'Billing' }).then(v => a = v)
await new Promise(r => setTimeout(r, 150))
const B = stxConfirm('Also delete all invoices?', { title: 'Invoices' }).then(v => b = v)
await new Promise(r => setTimeout(r, 250))
document.querySelectorAll('div[role=alertdialog]').length // 2
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await new Promise(r => setTimeout(r, 300))
a // false <-- answered
b // false <-- also answered, by the same keypress
document.querySelectorAll('div[role=alertdialog]').length // 0Every dialog adds its own capture-phase keydown listener on document (signals.ts:6700) and none of them check whether they are the topmost, so one Escape runs all of them. The two traps also compete for Tab while both are open. A module-level stack that only lets the last-opened dialog handle the key would fix both.
5. No prefers-reduced-motion guard
The open and close transitions are inline (transition: opacity 0.2s ease, transform 0.2s ease), so a stylesheet media query cannot reach them. Worth noting the primitive already exists in this very file - usePreferredReducedMotion() at signals.ts:5835 - it is just not consulted here.
6. The backdrop outlives the promise by 200ms and still swallows clicks
cleanup() calls resolve(result) immediately but defers backdrop.remove() by 200ms (signals.ts:6626).
const p = stxConfirm('timing', { title: 'T' })
await new Promise(r => setTimeout(r, 250))
document.querySelector('div[role=alertdialog] button:last-of-type').click()
await p // caller is now running
const bd = document.querySelector('div[role=alertdialog]')
!!bd // true
getComputedStyle(bd).pointerEvents // "auto"
getComputedStyle(bd).zIndex // "999999"
document.elementFromPoint(innerWidth/2, innerHeight/2).id // "stx-dialog-4-message"So for 200ms after await stxConfirm(...) returns, a full-viewport element at z-index:999999 is still hit-testing at the centre of the screen. Fading out is right; it should just stop taking pointer events the moment it starts (backdrop.style.pointerEvents = 'none' in cleanup).
Why this matters here
We are moving seven destructive confirmations - delete a server, rotate a token, disable 2FA, delete a passkey, cancel a subscription, delete a monitor, delete a status report - from native confirm() onto stxConfirm. Native confirm() gives us a real scroll lock and a genuinely modal surface for free; stxConfirm gives us theming, a focus trap and async. #3 is the one that would actually change our answer, because every one of those seven is an error-type confirm where the default keyboard action should be Cancel.
Happy to send a PR for any of these - #1, #3 and #6 are each a couple of lines, and #4 wants a small dialog stack.
Environment: main @ a198835, @stacksjs/stx 0.2.343.