Escape closes every open layer at once; full-page panels don't close on Escape #270

Closed
opened 2026-10-07 20:42:53 -04:00 by Salastil · 1 comment
Owner

25 components register 31 global Escape handlers on window/document, and none calls stopPropagation or checks defaultPrevented. A context menu or header popover open over a dialog closes together with the dialog.

No Escape handling at all: VerificationDialog, the MembershipGate rules dialog, the CallStage screen picker (CallStage.tsx:370), and the full-page Settings, Accounts, Downloads and Join panels - whose only exit is the X in the header (App.tsx:224-230). Discord's settings close on Esc.

Fix: a single layer stack (lib/layers.ts): each popup/dialog/panel pushes itself, and only the topmost one handles Escape. Migrate all 31 handlers to it.

Part of the v1.0 UI pass.

25 components register 31 global Escape handlers on `window`/`document`, and none calls `stopPropagation` or checks `defaultPrevented`. A context menu or header popover open over a dialog closes together with the dialog. No Escape handling at all: VerificationDialog, the MembershipGate rules dialog, the CallStage screen picker (CallStage.tsx:370), and the full-page Settings, Accounts, Downloads and Join panels - whose only exit is the X in the header (App.tsx:224-230). Discord's settings close on Esc. **Fix:** a single layer stack (`lib/layers.ts`): each popup/dialog/panel pushes itself, and only the topmost one handles Escape. Migrate all 31 handlers to it. _Part of the v1.0 UI pass._
Salastil added this to the v1.0 milestone 2026-10-07 20:42:53 -04:00
Salastil added the bug label 2026-10-07 20:42:53 -04:00
Author
Owner

Finished: c5e7619.

No picture - the change is in what Escape does. Each popup, dialog and panel now registers in the order it opened (lib/layers.ts, useEscapeLayer), and one window listener sends Escape to the last - the one on top - and to nothing else. About twenty window-level listeners moved onto it: the context menu, header popovers, emoji picker, lightbox, profile card, forward picker, reason prompt, export dialog, leave confirmation, event source, space rooms, room search, Discord modals and events, file drop, the call view's panels, the mentions inbox, the reader list, Kick points and add-to-conversation. A layer's place in the order is when it became active, not when it last re-rendered (its handler is read through a ref).

The full-page panels (Settings, Accounts, Downloads, Join) now close on Escape too, underneath whatever opened over them. A field that handles Escape itself - closing a suggestion list, cancelling a reply - still wins, because it prevents the default first. The message-read shortcut from #286 stands down when any layer is open.

Checked in the isolated test copy with real key events: with Settings open and the cog's menu opened over it, Escape 1 closed only the menu (panel still open), Escape 2 closed the panel; with the export dialog open, Escape closed it. Not changed: the handlers that live on a single input (the member filter, the thread reply box) - they only fire when that field has focus.

Finished: [c5e7619](https://git.salastil.com/Salastil/moho/commit/c5e761924aafcf8673c3972ecb1439c21f881df9). No picture - the change is in what Escape does. Each popup, dialog and panel now **registers in the order it opened** (`lib/layers.ts`, `useEscapeLayer`), and one window listener sends Escape to the **last** - the one on top - and to nothing else. About twenty window-level listeners moved onto it: the context menu, header popovers, emoji picker, lightbox, profile card, forward picker, reason prompt, export dialog, leave confirmation, event source, space rooms, room search, Discord modals and events, file drop, the call view's panels, the mentions inbox, the reader list, Kick points and add-to-conversation. A layer's place in the order is when it became active, not when it last re-rendered (its handler is read through a ref). **The full-page panels** (Settings, Accounts, Downloads, Join) now close on Escape too, underneath whatever opened over them. A field that handles Escape itself - closing a suggestion list, cancelling a reply - still wins, because it prevents the default first. The message-read shortcut from #286 stands down when any layer is open. Checked in the isolated test copy with real key events: with Settings open and the cog's menu opened over it, **Escape 1 closed only the menu** (panel still open), **Escape 2 closed the panel**; with the export dialog open, Escape closed it. Not changed: the handlers that live on a single input (the member filter, the thread reply box) - they only fire when that field has focus.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: Salastil/moho#270