Skip to content

Close hover popups on Esc and when focus leaves them - #15094

Open
cderv wants to merge 8 commits into
mainfrom
fix/issue-15080
Open

cderv wants to merge 8 commits into
mainfrom
fix/issue-15080

Conversation

@cderv

@cderv cderv commented Oct 16, 2026

Copy link
Copy Markdown
Member

Footnote, citation, cross-reference and code-annotation popups cannot be dismissed with Esc, and an interactive popup stays open after focus leaves it, covering the next focused element at narrow widths. This fails WCAG 1.4.13 (dismissible) and 2.4.11 (focus not obscured).

Root cause

The vendored tippy.js 6.3.7 has no Esc handling. Its focusout listener sits only on the trigger, so with interactive: true focus moving into the popup is ignored and nothing notices focus leaving it afterwards.

Fix

A tippy plugin, wired into both the tippyHover and the code-annotation configs, listens while a popup is shown:

  • Esc hides the popup. If focus is inside it, focus returns to the trigger, with the instance disabled meanwhile so the focus trigger does not reopen it.
  • focusout from the popup to an element outside both popup and trigger hides it. A null relatedTarget is ignored, so clicking non-focusable text inside the popup keeps it open.

A few cases needed more than the sketch in the issue:

  • Cross-reference popups call show() from onTrigger, ahead of tippy's own 100ms delayed show, which reopened the popup right after Esc. Pending delay timeouts are now cleared before hiding.
  • With code-annotations: select the trigger is click, and tippy has no blur handling for it. The trigger's focusout now closes the popup, and cancels a pending show when focus leaves within the 50 ms delay.
  • In revealjs, Esc is also bound to the slide overview. The keydown listener runs in the capture phase and stops propagation so Esc only closes the popup. It still propagates when the slide menu is open and handles keys itself, so a single Esc reaches the menu. A menu with keyboard: false handles nothing, so propagation requires keyboard handling to be on.

tippyOptions.config cannot override plugins: it is serialized with JSON.stringify and only set internally (revealjs offset and maxWidth).

The Playwright tests cover the four popup types in HTML, select mode, and revealjs. The tabUntilFocused helper was copied into four specs and the WebKit Alt+Tab mapping inlined in three more, so both now live in src/utils.ts.

Fixes #15080

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes
  • updated the appropriate changelog in the PR
  • ensured the present test suite passes
  • added new tests
  • created a separate documentation PR in Quarto's website repo and linked it to this PR
AI-assisted PR
  • AI tool used: Claude Code
  • Codebase grounding: local clone
  • Human review: I have reviewed, tested, and verified the AI-generated content before submitting.

Note: autonomous AI agents submitting PRs without human oversight are not permitted — see the Code of Conduct.

cderv added 8 commits October 16, 2026 15:27
Footnote, citation, cross-reference and code-annotation popups must close
on Esc (WCAG 1.4.13) and an interactive popup must close once focus
leaves both its trigger and the popup (WCAG 2.4.11). These fail on the
current tippy configuration, which has no Esc handling and only hides on
trigger blur.

The fixture disables code-copy because the copy button otherwise
intercepts hover on the one-line annotated cell.
code-annotations: select shares the hover tippy config, so its click
popups need the same Esc handling.

revealjs reuses the same popup code for citations, but reveal.js binds
Esc to the slide overview. Without dedicated handling, Esc opens the
overview, which hides the popup only as a side effect, so the test
asserts that the overview stays closed. Footnotes are not covered there
because revealjs renders them as slide asides without a popup.
Fixes #15080. The vendored tippy.js 6.3.7 has no Esc handling, and its
focusout listener sits only on the trigger: with interactive: true,
focus moving into the popup is ignored and nothing notices focus
leaving it afterwards. Footnote, citation and cross-reference popups
(tippyHover) and code-annotation popups were affected, failing WCAG
1.4.13 (dismissible) and, at narrow widths, 2.4.11 (focus obscured).

A tippy plugin, wired into both configs, listens while a popup is shown:

- Esc hides the popup. If focus is inside it, focus returns to the
  trigger, with the instance disabled meanwhile so the focus trigger
  does not reopen it.
- focusout from the popup to an element outside both popup and trigger
  hides it. A null relatedTarget is ignored so clicking non-focusable
  text inside the popup keeps it open.

Pending delay timeouts are cleared before hiding: cross-reference
popups call show() from onTrigger, ahead of tippy's own 100ms delayed
show, which otherwise reopened the popup right after Esc.

The keydown listener runs in the capture phase and stops propagation,
so in revealjs Esc closes the popup without also toggling the slide
overview.

tippyOptions.config cannot override plugins: it is serialized with
JSON.stringify and only set internally (revealjs offset/maxWidth).
tabUntilFocused was copied verbatim into four specs, and the WebKit
Alt+Tab mapping was inlined in three more. Both now live in src/utils.ts,
with the WebKit rationale stated once. tabKey takes a shift flag for the
Shift+Tab case.
The popup Esc handler stops propagation so that reveal.js does not also
toggle the slide overview. With a popup open, pressing M opens the slide
menu without hiding the popup or moving focus, so the next Esc closed
only the popup and the menu needed a second press. Propagation now
continues while the menu is open (RevealMenu isOpen()); the menu
handles Esc itself, and the overview still stays closed.

The test opens the popup with keyboard focus: a hover-opened popup can
close on its own once the menu covers the trigger, which makes the
scenario racy.
With code-annotations: select the tippy trigger is click, and tippy
installs no blur handling for it. Opening the popup with Enter and
pressing Tab left the popup open beside the next focused element. The
focus-out check now also runs on the trigger, so focus moving anywhere
but into the popup closes it. For focus triggers tippy already hides on
trigger blur, so the extra listener changes nothing there.
Esc over a popup was allowed to propagate whenever the reveal.js slide
menu was open, so the menu could close on the same keypress. RevealMenu
installs its Esc handler, and the keyboardCondition guard that stops
reveal.js from toggling the overview, only when its keyboard option is
on. With `menu: {keyboard: false, open-on-init: true}` the menu is open
but handles no keys, so dismissing a popup also opened the slide
overview. Propagation now requires the menu to be open with keyboard
handling enabled (RevealMenu treats an unset option as on).
With code-annotations: select, Enter schedules the popup behind the
50 ms trigger delay. The trigger's focusout listener was attached only
in onShow, and tippy has no blur handling for a click trigger, so
tabbing away within the delay still opened the popup with focus
elsewhere. The listener now lives for the instance's lifetime, so
leaving the trigger clears the pending show; the popup's own focusout
listener stays scoped to when it is shown.
@posit-snyk-bot

posit-snyk-bot commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

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.

a11y: footnote, citation, and cross-reference popups do not close with Esc

2 participants