Escape closes only the topmost dialog, menu or panel; menus close when focus leaves #140
Labels
No labels
bug
enhancement
needs-triage
ready-for-agent
ready-for-human
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
Cloonar/deckle#140
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
Give the editor one shared module that decides which open layer (a dialog, a menu or the Media details panel) an Escape, an outside press or a focus change closes, and move every dialog and menu and the details panel onto it. One Escape then closes only the topmost layer: today an Escape in a dialog also closes the Media details panel behind it. Every menu also closes when focus leaves it, as the header's menus already do: today the editor's own menus stay open, so two element menus can be open at once.
Current behavior
Escape closes more than the topmost layer. Reproduced in vitest/jsdom on
main(16bb339) during triage; the reporter saw the first case in headless Chromium.onKeyDown. It registers onekeydownlistener ondocumentand calls the handlers itself, so thestopPropagation()inDialog's and the menus' Escape handlers runs atdocument, the node where the details panel's and the element menu's own listeners also sit, and cannot keep the event from them. The comment above the panel's listener claims the opposite. Only the header's host-navigation menus (#139) avoid this, by taking Escape in the capture phase and stopping it there.Every layer handles dismissal on its own, with different rules (checked in code; the focus column and the rich-text row confirmed in jsdom):
HostNav.tsx)TreePane.tsx)PageEditorChrome.tsx)PopMenuinMediaModule.tsx)ElementMenuinPagePane.tsx)web/src/rte/RichTextField.tsx)Dialog.tsx)MediaModule.tsx)The rich-text menus close only on their own button, the other toolbar menu's button, a choice, or a click on a chip.
Desired behavior
One stack of open layers. Every dialog, every menu and the Media details panel is on the stack while it is open, in the order they opened.
isComposing), or that something already handled withpreventDefault()(a field that uses Escape itself), closes nothing.documentorwindow. #139 pinned this for the header menus; it now holds for all layers.Decisions
One shared module written in deckle, no library (maintainer, in triage). Considered:
<dialog>withshowModal()and popovers stack themselves, but jsdom, which runs every UI test, has neither (showModalandshowPopoverdon't exist there), the details panel is neither, andCloseWatcheris limited availability on MDN.Dialogand every menu would be rebuilt on their components. Kobalte 2.0 is in alpha.solid-dismissiblehas had no release since August 2024, and its stack follows the component tree, so sibling layers such as the details panel and a dialog each think they are on top, which is this bug again.@zag-js/dismissableis maintained and small, but it never sees a click into the cross-origin preview frame (it listens only inside same-origin frames and has no window-blur fallback), so deckle would keep its own code for that anyway, and deckle already maintains this logic, tested, in the header menus.The module generalizes the header menus' dismissal (#139), the way
dragReorder.tsbecame the one drag-and-drop skeleton (#124).The stack decides who closes, not event propagation. Under Solid's delegation every
onKeyDownhandler runs atdocument, sostopPropagation()cannot order handlers there, and the order of listener registration must not matter either.A field that handles Escape itself wins by calling
preventDefault(), the convention every Escape handler follows today, so a field inside a dialog can use Escape without closing the dialog.Every menu closes when focus leaves it, matching the header menus' documented behavior (README, "Dismissal and keyboard") and the WAI-ARIA menu pattern (Tab out of a menu closes it). This also fixes the element menu's two-open-at-once state; the maintainer chose to fix it in this issue rather than a separate one.
Escape returns focus to the menu's button (WAI-ARIA menu button pattern), as the header, page tree and page editor menus already do; the Media and element menus drop focus to the page body today.
The rich-text Insert and Table menus join. Today they ignore Escape, outside presses and focus changes; leaving them out would leave two menus outside the rules.
Layers are independent, unlike Zag's model, where closing a layer also closes every layer opened after it. In deckle a nested layer is a child component and unmounts with its parent anyway, while siblings such as the details panel and a dialog are unrelated.
Delete what the module replaces, in this issue: each component's own Escape handling (the Escape branches in
Dialog,PopMenu, the page tree and page editor menus; thedocumentEscape listeners of the details panel andElementMenu, including itsstopPropagation(), which does nothing atdocument), the outside-pressmousedownlisteners, the header menus'pointerdown/focusin/keydown/blurdismissal listeners, and the details panel's wrong comment. What stays where it is: arrow-key navigation inside menus, positioning (the header menus'scroll/resizeplacement, the fixed position of right-click menus),Dialog's focus handling and Tab trap, and the page tree's inline rename and new-page inputs, which keep their own Escape.Key interfaces
dragReorder.tsinweb/src/. Its name and API are the implementer's choice. A layer will need to give it its element (for a menu also its button), which rules apply to it, and a close callback that learns why it is closing (Escape, a press outside, focus leaving), so a menu can decide where focus goes.Dialog(web/src/components/Dialog.tsx), whosehandleKeyDownkeeps only the Tab trap.PopMenuand the details-panel effect inweb/src/components/MediaModule.tsx.web/src/components/TreePane.tsx(handleMenuKeyDownand itsmousedowneffect).web/src/components/PageEditorChrome.tsx(onMenuKeyDownand itsmousedowneffect).ElementMenuinweb/src/components/PagePane.tsx.web/src/components/HostNav.tsx.web/src/rte/RichTextField.tsx(insertOpen,tableOpen).Acceptance criteria
Escape:
isComposing: true, or one a field already handled withpreventDefault(), closes nothing.Menus (header host-navigation, page tree row, page editor header, Media tile / folder tile / folder-tree row, element ⋮, rich-text Insert and Table):
The module:
documentorwindow; closing the last layer removes exactly what opening the first one added.stopPropagation()can't order handlers under Solid's delegation.cd web && npm testpasses. Tests that pinned the old mechanics (the header menus' capture-phasekeydownand their own listener set, outside presses fired asmouseDown) are rewritten to pin the behavior instead.How to verify
cd web && npm test;make testruns the full suite (web build, vitest, Go).mainand pass after the change: unit tests for the module (besidesrc/test/dragReorder.test.ts), the cases above insrc/test/MediaModule.test.tsxandsrc/test/ElementCrud.test.tsx, and the Escape-focus and focus-leaving cases next to each menu's existing tests (src/test/HostNav.test.tsx,src/test/PageManage.test.tsx,src/test/rte/tableMenu.test.tsx, …).fireEvent.keyDown(el, { key: "Escape" })) so it goes through Solid's delegated listener as in a browser. A click into the preview frame is abluronwindow. A keyboard activation of a button isfireEvent.clickwith nomouseDownbefore it.Assumptions to check
web/srcagain for Escape handling,document/windowlisteners andaria-haspopup; anything else that opens over the page joins the module.preventDefault(). True today for the page tree's inline inputs; the "already handled" rule depends on it.blur; the header menus rely on this today (see the comment inHostNav.tsx).windowlistener in the bubble phase runs after everything ondocument, but astopPropagation()ondocumenthides the event from it. Check that every remainingstopPropagation()on a key event (Dialog's Tab trap, the page tree's inline inputs) is one that should hide it.Out of scope
scaffold/site/AGENTS.mdand README: nothing they document changes (README's header-menu paragraph stays true).Blocked by
None - can start immediately
Original report
Original title: "Media module: Escape in a dialog also closes the details panel behind it". The one-line fix it suggests was superseded in triage by the decisions above.
Media module: Escape in a dialog also closes the details panel behind itto Escape closes only the topmost dialog, menu or panel; menus close when focus leaves