Skip to content

fix(ui): Escape closes exactly one popup, topmost first (#535) - #790

Open
NotYuSheng wants to merge 1 commit into
devfrom
feature/535-modal-esc-audit
Open

fix(ui): Escape closes exactly one popup, topmost first (#535)#790
NotYuSheng wants to merge 1 commit into
devfrom
feature/535-modal-esc-audit

Conversation

@NotYuSheng

Copy link
Copy Markdown
Owner

Closes #535.

What was wrong

Escape handling was one ad-hoc document keydown listener per hand-rolled overlay, each unaware of the others and of react-bootstrap's own listener. With two layers on screen a press closed the wrong one, or two at once:

  • EntityDetailModal swallowed Escape with stopImmediatePropagation unless a nested IP panel was open — so the role-help, evidence-explainer and add-evidence modals opened from inside it were closed together with the whole panel.
  • ConversationPage closed the conversation modal on every Escape, on top of the modal's own onHide.
  • The graph pages hard-coded a "close the filter modal, then the node panel, then exit fullscreen" cascade that had to be kept in sync by hand.

What this does

New shared coordinator useEscapeLayer (frontend/src/utils/useEscapeLayer.ts), one capture-phase listener for all hand-rolled popups:

  • dispatches Escape to the topmost registered layer — ranked by painted stacking order (z-index path, so a 1080 nested inside a 1055 dialog does not out-stack a 1070 sibling at the document root; then document order), with registration order as the tiebreak;
  • yields when an SGDS modal is stacked above the layer, letting react-bootstrap close that modal itself;
  • stops propagation when it does handle the key, so no SGDS modal below closes in the same press;
  • lets an open info popover win outright: all 16 OverlayTriggers here are click + rootClose, which dismiss on Escape keyup, so swallowing the keydown dismisses the popover and spares the modal beneath it. A root-level guard in MainLayout keeps that rule working on pages with no layer of their own.

Audit results

SGDS Modals — all close on Escape via onHide. Four block dismissal on purpose and are left as-is: upload in progress (keyboard={!isUploading}), merge in progress (keyboard={false}), snapshot save while busy, pcap removal while deleting.

Hand-rolled popups — now on the shared stack: EntityDetailModal (+ its nested self), ConversationTracerModal, the cluster side panel, both geo-source popovers, the node-colour-priority explainer, and the four CSS-fullscreen modes (diagram + heatmap on the analysis page, diagram + heatmap in the monitor dialog).

Gaps this closes: the cluster side panel only answered Escape while fullscreen; the geo-source popovers and the node-colour explainer did not answer it at all (the latter also gained role="dialog").

Deliberately still not dismissible: the report-generation progress overlays on Analysis/Compare.

Testing

  • 11 unit tests for the coordinator (useEscapeLayer.test.tsx) covering topmost-only dispatch, unwinding a nested stack one press at a time, yielding to a modal above, not closing a modal below, the nested-stacking-context case, and the popover rule. Full suite: 641 passing.
  • Verified in the running stack (docker compose up -d --build):
    • conversation modal → tracer overlay → Escape closes the tracer only; second Escape closes the conversation;
    • conversation modal → host detail panel → "What is a role?" modal → three presses unwind exactly one layer each;
    • tracer → AI-explanation popover → Escape closes the popover, leaving both the tracer and the conversation modal open;
    • fullscreen diagram → filters modal → node-colour explainer → three presses unwind one layer each;
    • cluster side panel closes on Escape outside fullscreen.

🤖 Generated with Claude Code

Escape handling had grown one ad-hoc `document` keydown listener per hand-rolled
overlay, none of which knew about the others or about react-bootstrap's own
listener. With two layers on screen a single press either closed the wrong one or
closed both — EntityDetailModal, for instance, swallowed Escape with
`stopImmediatePropagation` whenever no nested *IP* panel was open, so the role
help / evidence explainer / add-evidence modals opened from inside it took the
whole panel down with them.

Replaces those listeners with a shared coordinator (`useEscapeLayer`):

- one capture-phase listener dispatches Escape to the topmost registered layer,
  ranked by painted stacking order (z-index path, then document order) with
  registration order as the tiebreak;
- it yields when an SGDS modal is stacked above the layer, so react-bootstrap
  closes that modal itself, and stops propagation when it does handle the key so
  no modal below closes in the same press;
- an open info popover wins outright: every OverlayTrigger here is
  click + rootClose, which dismisses on Escape *keyup*, so swallowing the keydown
  leaves the modal underneath alone.

Also closes gaps the audit turned up: the cluster side panel only responded to
Escape in fullscreen, and the geo-source popovers and the node-colour explainer
did not respond at all. The page/dialog fullscreen handlers lose their hand-rolled
"close this modal first" cascades — the stack now orders them.

Every SGDS Modal was inventoried: all close on Escape via `onHide` except the
four that block dismissal on purpose (upload in progress, merge in progress,
snapshot save, pcap removal).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 75421786-91a9-48b1-9143-85cb850fccdd

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant