Skip to content

fix(site): close the docs menu with Escape after a click - #62467

Merged
adamdotdevin merged 1 commit into
devfrom
docs-menu-escape
Nov 7, 2026
Merged

adamdotdevin merged 1 commit into
devfrom
docs-menu-escape

Conversation

@adamdotdevin

Copy link
Copy Markdown
Member

What

On a docs page, opening the "Docs ⌄" lockup menu with a click and pressing Escape left it open. Escape now closes any open docs menu or submenu however it was opened, and returns focus to its trigger.

Cause. Docs pages never hydrate, so src/docs/client.ts drives the lockup from delegated listeners. Its keydown listener began by finding the lockup root and, when the event came from the trigger, handled only the keys that open the menu (ArrowDown, Enter, Space, ArrowUp) and returned for everything else, Escape included:

if (target.matches("[data-slot=toggle], [data-slot=trigger]")) {
  if (key === "ArrowDown" || key === "Enter" || key === " ") openLockup(root, "current")
  else if (key === "ArrowUp") openLockup(root, "last")
  else return
  return event.preventDefault()
}

Keyboard opening moves focus into the menu, so Escape arrived from a menu row and reached the branch that closes it. A click leaves focus on the trigger (and in Safari, on the page, since clicking a button does not focus it), so Escape hit that early return.

Fix. Escape is handled before the trigger keys and without requiring the event to start inside the lockup: while a lockup is open it closes an open flyout submenu first (focusing the "Docs" row), otherwise the menu, focusing the trigger.

The same check turned up one more instance: the search dialog listened for Escape on the dialog element, so clicking inside the panel but not in its field moved focus to the page and Escape stopped closing search. It now closes from a document listener. The phone menu sheet, the phone section menu and the Contents bar already closed from document-level listeners, so they are unchanged.

Verification

Headless Playwright against vite dev and against the built docs (bun src/docs/script/build.ts --out …), desktop 1400×900 and phone 420×800:

  • click-open → Escape closes and focus returns to the trigger
  • keyboard-open (ArrowDown) → Escape closes and focus returns to the trigger
  • click outside still closes
  • keyboard-open, ArrowRight into the flyout → Escape closes only the submenu and focuses the "Docs" row, a second Escape closes the menu
  • hover-open flyout → Escape closes the menu and focuses the trigger
  • menu open with focus on the page (the Safari click case) → Escape closes and focuses the trigger
  • search → Escape closes and focuses its opener, including after a click on the panel's background
  • phone: section menu, menu sheet, Contents bar and the nested lockup menu all close on Escape and focus their trigger

bun typecheck, bun run typecheck:tsc, bun test (192 pass), bun run build and root bun run check all pass. The docs client bundle is 5.7 kB gzipped (budget 6.5 kB).

Visual

<svg xmlns="http://www.w3.org/2000/svg" width="240" height="72" viewBox="0 0 240 72" fill="none" role="img" aria-label="The docs menu closes when Escape is pressed">
  <style>
    .menu { animation: menu 4s ease-in-out infinite; }
    .key { animation: key 4s ease-in-out infinite; }
    @keyframes menu {
      0%, 6% { opacity: 0; transform: translateY(-3px) scale(0.97); }
      18%, 58% { opacity: 1; transform: none; }
      70%, 100% { opacity: 0; transform: translateY(-3px) scale(0.97); }
    }
    @keyframes key {
      0%, 50%, 74%, 100% { transform: translateY(0); }
      56%, 64% { transform: translateY(2px); }
    }
    @media (prefers-reduced-motion: reduce) { .menu, .key { animation: none; } }
  </style>
  <rect x="16" y="10" width="62" height="20" rx="6" stroke="currentColor" stroke-opacity="0.35"/>
  <text x="24" y="23.5" font-family="ui-sans-serif, system-ui, sans-serif" font-size="9" fill="currentColor" fill-opacity="0.8">Docs</text>
  <path d="M62 18.5L65 21.5L68 18.5" stroke="currentColor" stroke-opacity="0.6" stroke-width="1.25" stroke-linecap="round"/>
  <g class="menu">
    <rect x="16" y="36" width="96" height="26" rx="6" fill="currentColor" fill-opacity="0.04" stroke="currentColor" stroke-opacity="0.3"/>
    <rect x="24" y="43" width="38" height="3" rx="1.5" fill="currentColor" fill-opacity="0.55"/>
    <rect x="24" y="52" width="26" height="3" rx="1.5" fill="currentColor" fill-opacity="0.3"/>
  </g>
  <g class="key">
    <rect x="168" y="22" width="32" height="18" rx="4" stroke="currentColor" stroke-opacity="0.45"/>
    <text x="184" y="34" text-anchor="middle" font-family="ui-monospace, monospace" font-size="8" fill="currentColor" fill-opacity="0.8">Esc</text>
  </g>
</svg>

The docs' delegated keydown handler returned early for every key pressed on the
lockup's trigger, so Escape after a click-open (which leaves focus on the
trigger) left the menu open. Escape is now handled before the trigger keys, from
anywhere on the page, and still closes a flyout submenu first. Search closes on
Escape from the document too, so it works after a click moves focus out of the
panel.
@adamdotdevin
adamdotdevin enabled auto-merge (squash) November 7, 2026 01:34
@adamdotdevin
adamdotdevin merged commit d2b3ae0 into dev Nov 7, 2026
15 checks passed
@adamdotdevin
adamdotdevin deleted the docs-menu-escape branch November 7, 2026 01:35

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The fix works. I built the docs from the base and from this branch and ran the built client in happy-dom, a simulated browser DOM (no browser was available here). On the base, a click-opened docs menu stays open on Escape, and search stays open when Escape is pressed with focus on the page. With this change both close, and focus goes back to the toggle or to the search opener. Keyboard opening still behaves as before. In services/site, bun test and typecheck:tsc pass.

The public API is unchanged; only docs client code is touched.

One small regression: when search is opened over an open menu, Escape now closes both, and focus is lost (see the inline comment).

// Escape closes the lockup wherever focus is: a click on its trigger may leave focus on the trigger (or,
// in Safari, on the page), and keyboard opening moves it into the menu, so it can't wait for a keydown
// that started inside the menu.
if (key === "Escape" && lockup) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This now also runs while search is open on top of the menu. To reproduce: open the menu with the keyboard, press Ctrl+K, then press Escape in the search field. On the base, Escape closed only search and focus went back to the menu row. Now it closes the menu too, and closeSearch then focuses its opener, which is that menu row, now hidden. Focus ends up nowhere. Skipping this branch while !dialog.hidden (or closing the lockup when search opens) would keep the old behaviour.

autonull pushed a commit to autonull/opencode_spacegraph that referenced this pull request Nov 7, 2026
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