ReviewOS

stacks/stx

Dialog is aria-modal but has no focus trap, no scroll lock, and closes on neither Esc nor backdrop click

#1978
Closed glennmichael123 opened this 8 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
8 days ago

Summary

<Dialog> announces itself as role="dialog" aria-modal="true" but implements none of the behaviour that contract implies. Built from the library's own parts, a dialog opens and then cannot be closed by any of the three ways a user will try, and the page behind it is still fully interactive.

Reproduction

Exactly the composition the component README implies:

<Dialog :open="dlgOpen()" @close="dlgOpen.set(false)">
  <DialogBackdrop />
  <DialogPanel>
    <DialogTitle>Delete this workout?</DialogTitle>
    <DialogDescription>This cannot be undone.</DialogDescription>
  </DialogPanel>
</Dialog>

Open it, then measure:

BehaviourResult
Click the backdropdialog stays open
Press Escdialog stays open
document.body scroll lockoverflow: visible — the page scrolls behind the modal
Focus moved into the dialogfalse — focus stays on the trigger button
Focus trapnone — Tab walks straight out into the page behind

There is no built-in close affordance either, so a dialog whose author does not supply their own Cancel button is a dead end.

Why Esc does not work

Dialog.stx binds the handler to its own root element:

<div :show="isOpen()" role="dialog" aria-modal="true" @keydown="onBackdropKey($event)">

Since focus is never moved into that element when the dialog opens, the keydown never reaches it. A synthetic keydown dispatched on the dialog node does close it — so the handler is correct and simply unreachable. The two defects compound: fixing focus would also fix Esc.

Why backdrop click does not work

DialogBackdrop.stx is aria-hidden="true" with no handler, and Dialog does not listen for clicks outside its panel. Nothing is wired to the dismiss path.

(The backdrop is also fully opaque — that is #1977, filed separately.)

Prior art in this same package

Drawer.stx gets all of this right: it locks body scroll (overflow: hidden measured), moves focus into the panel, scopes the focus lookup to its own $uid to survive two drawers on a page, and renders a working close button. Dialog reads like the version of Drawer from before that work landed.

Expected

Dialog should, on open: move focus into the panel, trap Tab within it, lock body scroll, restore focus to the trigger on close, and close on Esc and on backdrop click — with an opt-out for the latter two, since a destructive confirm sometimes wants to insist.

Until then, an app cannot use <Dialog> for a modal without reimplementing the modal part, which is most of it.

Environment

  • @stacksjs/components 0.2.339, stacks 0.75.31, Bun 1.3.x, macOS
  • Found while migrating hq.training onto the component library — 21 hand-rolled modals there, all of which currently hand-roll backdrop dismissal because the component does not provide it.

Sign in to comment on this issue.