Skip to content

Fix Escape handling for the About modal#449

Merged
tracygardner merged 9 commits intomainfrom
codex/troubleshoot-escape-key-behavior
Mar 21, 2026
Merged

Fix Escape handling for the About modal#449
tracygardner merged 9 commits intomainfrom
codex/troubleshoot-escape-key-behavior

Conversation

@tracygardner
Copy link
Contributor

@tracygardner tracygardner commented Mar 21, 2026

Motivation

  • The About modal and main menu had fragmented Escape handling which allowed other handlers to intercept the key and left modal state/focus inconsistent when opened or closed.
  • The modal close control was a non-interactive span, preventing reliable keyboard focus and predictable behaviour for assistive users.

Description

  • Converted the modal close control from a span to a real <button> in index.html so it is keyboard-focusable and accessible.
  • Added openInfoModal() and hideInfoModal() helpers in main/menu.js that centralize showing/hiding the modal, update ARIA attributes, and restore previously-focused elements.
  • Centralized a capture-phase keydown handler inside the menu module to handle Escape and ensure it closes the About modal and/or any open menu before other UI code runs.
  • Updated menu and modal handlers to use the new helpers and to prevent propagation when Escape is handled so behaviour is consistent across keyboard and mouse interactions.

Testing

  • Ran npm run build and the production build completed successfully.
  • Ran npx eslint main/menu.js and there were no linting errors in the modified file.
  • Attempted npx prettier --write main/menu.js index.html, but formatting of index.html failed due to a pre-existing HTML syntax issue in a separate commented language menu section (Prettier did reformat main/menu.js).

Codex Task

Summary by CodeRabbit

  • Bug Fixes

    • Replaced a non-interactive modal close element with a semantic button for improved accessibility.
    • Overlay and outside clicks reliably dismiss the info modal.
  • New Features

    • Escape now consistently closes the info modal and the color picker; focus is saved and restored when opening/closing.
    • Global Escape handling added for the color picker to ensure consistent behavior.
  • Refactor

    • Menu and keyboard navigation reorganized for clearer, more reliable keyboard, Tab, and Escape interactions.

@coderabbitai
Copy link

coderabbitai bot commented Mar 21, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Replaces the modal close span with a semantic close button; adds canRestoreFocus(), openInfoModal(), and hideInfoModal() to centralize modal visibility, ARIA, and focus restoration; refactors AccessibleFlyoutMenu behavior and adds a capture-phase Escape handler for the color picker.

Changes

Cohort / File(s) Summary
HTML Modal
index.html
Replaced non-interactive <span> close glyph with a semantic <button type="button"> (class="close-button", id="closeInfoModal") containing &times;.
Modal helpers & flow
main/menu.js
Added canRestoreFocus(), openInfoModal(), hideInfoModal() to manage modal show/hide, aria-hidden/aria-modal, capture/restore previously focused element, and focus the modal close button on open. Rewired About click, overlay click, close-button, and Escape flows to use these helpers.
AccessibleFlyoutMenu refactor
main/menu.js
Reworked flyout internals: removed global dropdown reliance, added focusout-driven closing, Tab-to-close behavior, expanded Escape handling (prefers closing info modal first), synthetic click dispatch for keyboard leaf activation, and other keyboard/focus improvements.
Global input handling (Color picker)
ui/colourpicker.js
Added a capture-phase globalEscapeHandler registered on picker open() and removed on close() to intercept Escape, close the picker when appropriate, and restore focus to #colorPickerButton.
Cross-cutting behavior
main/menu.js, ui/colourpicker.js
Centralized Escape and click-outside handling: menu-level Escape now may close the info modal first (with preventDefault/stopPropagation), and overlay clicks delegate to hideInfoModal() when target equals modal.

Sequence Diagram(s)

sequenceDiagram
  participant User as User
  participant Menu as AccessibleFlyoutMenu
  participant Modal as InfoModal
  participant Picker as CustomColorPicker
  participant Window as Window/global

  User->>Menu: Click "About"
  Menu->>Menu: menuFlyout?.closeAllMenus()
  Menu->>Modal: openInfoModal()
  Note right of Modal: set aria-modal, remove hidden\nsave previouslyFocused, focus close button

  User->>Window: Press Escape
  Window->>Menu: global Escape handler
  alt Modal is open
    Menu->>Modal: hideInfoModal()
    Note right of Modal: restore previouslyFocused\nadd hidden, update aria attributes
  else Modal not open
    Menu->>Menu: handle flyout Escape (close menu)
  end

  User->>Picker: Open color picker
  Picker->>Window: register capture Escape handler
  User->>Window: Press Escape
  Window->>Picker: capture Escape -> Picker closes and focuses button
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

Possibly related PRs

  • Tidy index #409: Touches the same menu/modal code paths and likely overlaps with the AccessibleFlyoutMenu refactor and modal helpers.

Poem

🐰
A button hops in where a span once lay,
Focus finds its path and keys now play.
Menus tuck in, modals take the stage,
Escape skims out and closes the page. 🥕✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Fix Escape handling for the About modal' directly and accurately summarizes the primary change—centralizing Escape key handling for the modal and menu.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch codex/troubleshoot-escape-key-behavior

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

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@main/menu.js`:
- Around line 9-29: previouslyFocused can point at a hidden <li> inside the
flyout (`#menuDropdown`) so hideInfoModal() may try to focus a non-visible node;
update hideInfoModal() to validate previouslyFocused before calling focus(): if
previouslyFocused is not in document, is hidden (offsetParent null or
aria-hidden/hidden class), or is contained within the `#menuDropdown` element,
instead move focus to the stable trigger element (`#menuBtn`); after focusing the
chosen element clear previouslyFocused. Reference the functions and symbols:
hideInfoModal, openInfoModal, previouslyFocused, `#menuDropdown`, and `#menuBtn`.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 772b6480-21c9-4c1d-8647-a7820c98131b

📥 Commits

Reviewing files that changed from the base of the PR and between 57a3087 and 24d8825.

📒 Files selected for processing (2)
  • index.html
  • main/menu.js

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@ui/colourpicker.js`:
- Around line 91-99: The escape key handler this.globalEscapeHandler should
block other window/key listeners; replace the call to event.stopPropagation()
with event.stopImmediatePropagation() inside the handler (keeping
event.preventDefault()), so when the picker is open and not _eyedropperActive
and event.key === "Escape" the handler calls event.preventDefault();
event.stopImmediatePropagation(); then this.close(); and focuses the
"colorPickerButton" element; update references in the same function (isOpen,
_eyedropperActive, close, colorPickerButton) accordingly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b1dd8a03-59fd-4f6a-943c-f4d04c90acfb

📥 Commits

Reviewing files that changed from the base of the PR and between 24d8825 and ae71e90.

📒 Files selected for processing (1)
  • ui/colourpicker.js

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
main/menu.js (1)

132-139: Collapse the duplicated outside-click menu closer.

Lines 132-139 and Lines 420-423 both run the same outside-click closeAllMenus() check. It works, but every outside click now executes the close path twice and future dismissal changes have to stay in sync in two places. I’d keep one menu outside-click handler and leave the modal-overlay branch separate.

Also applies to: 420-423

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@main/menu.js` around lines 132 - 139, Duplicate outside-click handlers are
registered: consolidate the logic so only one global outside-click listener
checks the menu and calls this.closeAllMenus() (the current listener using
document.addEventListener and checking this.menuButton and this.menuDropdown)
and remove the second identical handler; keep the separate modal-overlay
dismissal logic as its own branch (do not merge modal-overlay behavior into the
menu handler), and ensure the remaining listener is bound to the class instance
(use the same method reference for addEventListener/removeEventListener) so
future changes affect a single location (refer to document.addEventListener,
this.menuButton, this.menuDropdown, and this.closeAllMenus).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@main/menu.js`:
- Around line 142-168: The Escape key capture-phase handler added with
window.addEventListener in main/menu.js should call e.stopImmediatePropagation()
instead of e.stopPropagation() so subsequent capture-phase listeners (like the
colour picker) on the same target don't run; update the event listener callback
(the anonymous function that checks e.key, calls hideInfoModal(), inspects
this.isMenuOpen, calls this.closeAllMenus() and this.menuButton.focus()) to use
e.stopImmediatePropagation() when handled is true, and make the analogous change
in the colour picker escape handler in ui/colourpicker.js so both handlers
consistently prevent later same-phase listeners.

---

Nitpick comments:
In `@main/menu.js`:
- Around line 132-139: Duplicate outside-click handlers are registered:
consolidate the logic so only one global outside-click listener checks the menu
and calls this.closeAllMenus() (the current listener using
document.addEventListener and checking this.menuButton and this.menuDropdown)
and remove the second identical handler; keep the separate modal-overlay
dismissal logic as its own branch (do not merge modal-overlay behavior into the
menu handler), and ensure the remaining listener is bound to the class instance
(use the same method reference for addEventListener/removeEventListener) so
future changes affect a single location (refer to document.addEventListener,
this.menuButton, this.menuDropdown, and this.closeAllMenus).

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 4a2615f1-93c5-4848-98ff-6908a5c04cfe

📥 Commits

Reviewing files that changed from the base of the PR and between ae71e90 and 6b85e00.

📒 Files selected for processing (1)
  • main/menu.js

Comment on lines +142 to +168
window.addEventListener(
"keydown",
(e) => {
if (e.key !== "Escape") {
return;
}

// Close menu on Escape
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && this.isMenuOpen) {
this.closeAllMenus();
this.menuButton.focus();
}
}, true); // Fire before blockly handles escape
}
let handled = false;

if (!infoModal.classList.contains("hidden")) {
hideInfoModal();
handled = true;
}

toggleMainMenu() {
if (this.isMenuOpen) {
this.closeAllMenus();
} else {
this.openMainMenu();
this.closeAllMenus();
this.menuButton.focus();
handled = true;
}
}

openMainMenu() {
this.isMenuOpen = true;
this.menuDropdown.classList.remove('hidden');
this.menuButton.setAttribute('aria-expanded', 'true');
this.currentFocus = -1;
if (handled) {
e.preventDefault();
e.stopPropagation();
}
},
true,
); // Fire before blockly handles escape
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -eu

echo "== main/menu.js =="
rg -nC3 'DOMContentLoaded|window\.addEventListener\(\s*["'"'"']keydown["'"'"']|Escape|stopImmediatePropagation\(\)|stopPropagation\(\)' main/menu.js

COLOURPICKER_FILE="$(fd 'colourpicker\.js$' . -t f | head -n1 || true)"
if [ -n "${COLOURPICKER_FILE}" ]; then
  echo
  echo "== ${COLOURPICKER_FILE} =="
  rg -nC3 'window\.addEventListener\(\s*["'"'"']keydown["'"'"']|Escape|stopImmediatePropagation\(\)|stopPropagation\(\)' "${COLOURPICKER_FILE}"
fi

Repository: flipcomputing/flock

Length of output: 5425


🏁 Script executed:

#!/bin/bash
set -eu

echo "=== Searching for all window.addEventListener with capture phase (3rd arg = true) ==="
rg -n 'window\.addEventListener\(' . -t js | head -30

echo ""
echo "=== Searching specifically for capture phase keydown listeners ==="
rg -B2 -A2 'window\.addEventListener\s*\(\s*["\x27]keydown' . -t js

echo ""
echo "=== Checking order of execution: when is DOMContentLoaded vs color picker open ==="
echo "From menu.js:"
rg -n 'DOMContentLoaded|new AccessibleFlyoutMenu' main/menu.js
echo ""
echo "From colourpicker.js:"
rg -n 'open\(\)' ui/colourpicker.js | head -5

Repository: flipcomputing/flock

Length of output: 50377


🏁 Script executed:

#!/bin/bash
set -eu

echo "=== Blockly keyHandler registration and listener details ==="
sed -n '1780,1800p' main/blocklyinit.js

echo ""
echo "=== Check if this keyHandler is capture phase ==="
rg -B5 -A5 'keyHandler.*Escape|Escape.*keyHandler' main/blocklyinit.js | head -20

Repository: flipcomputing/flock

Length of output: 967


🏁 Script executed:

#!/bin/bash
set -eu

echo "=== Check activePickHandlerCapture value ==="
rg -n 'activePickHandlerCapture' . -t js | head -10

echo ""
echo "=== Check addmenu.js for Escape and capture phase ==="
rg -B3 -A3 'Escape|stopPropagation' ui/addmenu.js | head -30

echo ""
echo "=== Summary: all window Escape handlers in capture phase ==="
rg -B2 'window\.addEventListener\s*\(\s*["\x27]keydown' . -t js | grep -A2 'true\|capture'

Repository: flipcomputing/flock

Length of output: 50376


Switch to stopImmediatePropagation() to prevent the colour picker Escape handler from executing when the menu handler runs.

The capture-phase listener at lines 142-168 uses stopPropagation() (line 164), which only prevents event propagation to parent targets during the bubbling phase. It does not block other listeners on the same window target in the capture phase. The colour picker registers a similar capture-phase Escape handler (at line 1999 in ./ui/colourpicker.js) that will still execute even if the menu handler prevents default and stops propagation. Use stopImmediatePropagation() instead to prevent subsequent listeners on the same target and phase from running, ensuring the menu intercepts Escape before the colour picker's handler can fire.

This applies to both the menu handler (lines 142-168) and the colour picker handler (line 1999 of ./ui/colourpicker.js).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@main/menu.js` around lines 142 - 168, The Escape key capture-phase handler
added with window.addEventListener in main/menu.js should call
e.stopImmediatePropagation() instead of e.stopPropagation() so subsequent
capture-phase listeners (like the colour picker) on the same target don't run;
update the event listener callback (the anonymous function that checks e.key,
calls hideInfoModal(), inspects this.isMenuOpen, calls this.closeAllMenus() and
this.menuButton.focus()) to use e.stopImmediatePropagation() when handled is
true, and make the analogous change in the colour picker escape handler in
ui/colourpicker.js so both handlers consistently prevent later same-phase
listeners.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
main/menu.js (1)

162-165: ⚠️ Potential issue | 🟠 Major

Use stopImmediatePropagation() in the capture Escape handler.

Line 164 still allows later window capture listeners to run. Escape can leak into other global handlers even after this block closes the modal/menu.

🔧 Proposed fix
         if (handled) {
           e.preventDefault();
-          e.stopPropagation();
+          e.stopImmediatePropagation();
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@main/menu.js` around lines 162 - 165, Replace the current e.stopPropagation()
call in the Escape key capture handler with e.stopImmediatePropagation() so
that, when handled is true, the event is prevented from reaching any later
window capture listeners; locate the Escape/capture handler in main/menu.js (the
block checking handled, calling e.preventDefault() and e.stopPropagation()) and
change the second call to e.stopImmediatePropagation() (or add it before
stopPropagation if you prefer), keeping the e.preventDefault() and handled guard
intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@main/menu.js`:
- Around line 421-428: The global click handler uses menuFlyout before it may be
initialized; guard that usage by checking that menuFlyout is defined (and not
null) before calling menuFlyout.closeAllMenus() inside the
window.addEventListener callback. Specifically, in the click listener that
references menuBtn, menuDropdown, and infoModal, wrap the
menuFlyout.closeAllMenus() call (or the whole conditional that checks
menuBtn/menuDropdown) with a truthy check for menuFlyout (e.g., if (menuFlyout
&& !menuBtn.contains(e.target) && !menuDropdown.contains(e.target)) {
menuFlyout.closeAllMenus(); }). Ensure the infoModal hide path remains reachable
even if menuFlyout is uninitialized.

---

Duplicate comments:
In `@main/menu.js`:
- Around line 162-165: Replace the current e.stopPropagation() call in the
Escape key capture handler with e.stopImmediatePropagation() so that, when
handled is true, the event is prevented from reaching any later window capture
listeners; locate the Escape/capture handler in main/menu.js (the block checking
handled, calling e.preventDefault() and e.stopPropagation()) and change the
second call to e.stopImmediatePropagation() (or add it before stopPropagation if
you prefer), keeping the e.preventDefault() and handled guard intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: aac0d8cb-60e6-4639-a84c-cd7e4284bcd3

📥 Commits

Reviewing files that changed from the base of the PR and between 6b85e00 and 514ecc4.

📒 Files selected for processing (1)
  • main/menu.js

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
main/menu.js (1)

234-252: Minor redundancy in leaf item keyboard activation.

When activated via keyboard, handleMenuItemClick dispatches a synthetic click which triggers the click handler, calling handleMenuItemClick again (this time with e.type="click"). Both code paths end up calling closeAllMenus(). This is harmless since the method is idempotent, but could be simplified.

♻️ Optional simplification
   handleMenuItemClick(e, item) {
     const submenu = item.querySelector(".submenu");
     if (submenu) {
       e.preventDefault();
       this.toggleSubmenu(item);
     } else {
       // For leaf menu items (like "About"), trigger the actual click event
-      // This will fire any existing click handlers you have attached
-      if (e.type === "keydown") {
-        // If this was triggered by keyboard, create a synthetic click event
-        const clickEvent = new MouseEvent("click", {
-          bubbles: true,
-          cancelable: true,
-        });
-        item.dispatchEvent(clickEvent);
-      }
-      this.closeAllMenus();
+      this.closeAllMenus();
+      // Only dispatch synthetic click for keyboard activation to trigger item's handlers
+      if (e.type === "keydown") {
+        item.click();
+      }
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@main/menu.js` around lines 234 - 252, In handleMenuItemClick, dispatching a
synthetic click for keyboard activation causes the click handler to run and then
closeAllMenus may be called twice; fix this by short-circuiting after
dispatching the synthetic MouseEvent so we don't fall through to the subsequent
closeAllMenus call — e.g., in handleMenuItemClick, after
item.dispatchEvent(clickEvent) return immediately (or otherwise ensure
closeAllMenus is only invoked once), while keeping the existing submenu logic
(toggleSubmenu) unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@main/menu.js`:
- Around line 234-252: In handleMenuItemClick, dispatching a synthetic click for
keyboard activation causes the click handler to run and then closeAllMenus may
be called twice; fix this by short-circuiting after dispatching the synthetic
MouseEvent so we don't fall through to the subsequent closeAllMenus call — e.g.,
in handleMenuItemClick, after item.dispatchEvent(clickEvent) return immediately
(or otherwise ensure closeAllMenus is only invoked once), while keeping the
existing submenu logic (toggleSubmenu) unchanged.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6b739f9f-9576-4102-8c52-bc0180041f53

📥 Commits

Reviewing files that changed from the base of the PR and between 514ecc4 and 127bfd2.

📒 Files selected for processing (1)
  • main/menu.js

@tracygardner tracygardner merged commit aed5a0f into main Mar 21, 2026
7 checks passed
@tracygardner tracygardner deleted the codex/troubleshoot-escape-key-behavior branch March 21, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant