Skip to content

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

Merged
NotYuSheng merged 8 commits into
devfrom
feature/535-modal-esc-audit
Sep 12, 2026
Merged

NotYuSheng merged 8 commits 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: Advanced

Run ID: 22e73971-378a-4b2c-8311-7cb6ddc5261c

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.

NotYuSheng and others added 7 commits September 12, 2026 10:11
- topLayer() no longer lets a layer whose ref.current is temporarily
  null (e.g. an unmounted-then-remounting CSS-fullscreen pane) win the
  stacking tie-break outright; such layers are skipped instead.
- Nested popups inside EntityDetailModal (role help, evidence
  explainer, add-evidence) now always raise above it via
  tp-nested-modal, instead of only when the parent was given an
  explicit zIndex prop — no real call site ever passed one, so the
  #535 bug this PR fixes could still reproduce.
- NetworkControls' "Node colours" explainer now opens an SGDS Modal
  instead of a hand-rolled dialog behind a native title= tooltip, per
  CLAUDE.md's popup-affordance rule.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWoEzqBqJDSu4mk6GAeQYe
Reverts the SGDS-Modal conversion from the previous commit so we can
test the original useEscapeLayer-based Escape behavior directly,
rather than swapping it for a different mechanism. The two coordinator
correctness fixes (null-ref tie-break, EntityDetailModal nested-modal
z-index) stay in place.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWoEzqBqJDSu4mk6GAeQYe
Undo the correctness patches from the previous two commits
(useEscapeLayer.ts, EntityDetailModal + its child sections, and
NetworkControls) so the branch matches the original PR #790 content
exactly (aside from the dev sync merge). Reverted per request pending
a closer look at whether useEscapeLayer duplicates Escape handling
that SGDS Modal already provides by default.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWoEzqBqJDSu4mk6GAeQYe
Phase 1 of replacing hand-rolled Escape overlays with real SGDS
components (see plan). This overlay was already modal-shaped (centered
card, backdrop-click-to-close already implemented) so it converts
directly: drop the hand-rolled <div> + useEscapeLayer registration in
favor of <Modal>, which gets correct Escape-stacking for free from
react-bootstrap's shared ModalManager. The unrelated ArrowLeft/
ArrowRight step-navigation keydown listener is untouched.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWoEzqBqJDSu4mk6GAeQYe
Phase 2 of replacing hand-rolled Escape overlays with real SGDS
components (see plan). EntityDetailModal was a <div> wearing Bootstrap
CSS classes, invisible to react-bootstrap's shared ModalManager — that
invisibility is the root cause #535 had to work around. Convert it to
a real <Modal>: drop the hand-rolled backdrop/click-to-close, the
naive non-ref-counted body-scroll-lock effect, and the useEscapeLayer
registration, all superseded by Modal's own ModalManager-backed
handling.

Peer-navigation self-nesting (a second EntityDetailModal opened from
inside the first) no longer needs a manual +10 z-index offset — two
real Modals stack correctly via the shared ModalManager automatically.
This removes the entire zIndex/raisedModal prop-threading chain
through HostIdentitySection and AdjudicationPanel, and the
.tp-nested-modal/-backdrop CSS classes that existed only to raise
their inner Modals (role-help, evidence explainer, add/edit evidence)
above the old hand-rolled parent.

Also drop enforceFocus={false} from the two Modals that render an
EntityDetailModal as a sibling (SnapshotDetailModal, SubnetDiagramModal)
— that workaround existed only because the hand-rolled child was
invisible to Bootstrap's focus trap; a real nested Modal doesn't need
it. Needs manual verification that tabbing into the nested panel still
works correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWoEzqBqJDSu4mk6GAeQYe
Phase 3 of replacing hand-rolled Escape overlays with real SGDS
components (see plan). Investigated whether all 5 CSS-fullscreen
panes (.nd-css-fullscreen toggle + useEscapeLayer) could convert to a
real Modal fullscreen: NetworkGraph (Sigma.js, used by both diagram
panes) holds pan/zoom camera state only in the live Sigma instance
with no saved-viewport prop, so branch-swapping Card<->Modal would
unmount/remount it and reset the user's view on every fullscreen
toggle — a real regression. VolumeHeatmap (both heatmap panes) and
ClusterGraph (ReactFlow, ClusterGraph) have no such state at risk:
VolumeHeatmap only has a discrete zoom step + scroll position that
resets to "fit" anyway, and ClusterGraph already auto-refits on
nearly every data/groupBy change and already remounts its own
ReactFlow branch when groupBy toggles to/from 'country'.

Convert the 3 low-risk panes — NetworkDiagramPage's heatmap,
SnapshotDetailModal's heatmap (nested inside its own Modal.Body), and
NetworkClusterPage's cluster graph — to real <Modal fullscreen>,
dropping their useEscapeLayer registrations, refs, and the
.nd-css-fullscreen/-over-modal CSS classes they used. Also remove the
container={graphCardRef.current} hack on NetworkClusterPage's filter
modal (only needed so it portaled inside the old hand-rolled
fullscreen card) and the vestigial, always-dead .card:fullscreen CSS
in ClusterGraph.css (native :fullscreen was never triggered — nothing
called requestFullscreen — so the cluster graph canvas was clamped to
70vh even in "fullscreen" mode; replaced with real .modal-fullscreen
flex-fill rules that actually apply).

Left untouched, on the existing CSS-toggle + useEscapeLayer approach:
NetworkDiagramPage's and SnapshotDetailModal's diagram fullscreen
(both wrap NetworkGraph/Sigma) — this is a deliberate scope decision,
not an oversight; useEscapeLayer keeps these 2 consumers going
forward.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TWoEzqBqJDSu4mk6GAeQYe
@NotYuSheng
NotYuSheng merged commit 73fffe7 into dev Sep 12, 2026
4 of 6 checks passed
@NotYuSheng
NotYuSheng deleted the feature/535-modal-esc-audit branch September 12, 2026 11:45
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