From 712b601baa2156044529c0b34ea094fa66b0b9c6 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 16 Oct 2026 15:27:38 +0200 Subject: [PATCH 1/8] Add tests for dismissing hover popups with Esc and focus-out 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. --- .../playwright/html/hover-popups-dismiss.qmd | 38 ++++++ .../tests/html-hover-popups-dismiss.spec.ts | 116 ++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/docs/playwright/html/hover-popups-dismiss.qmd create mode 100644 tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts diff --git a/tests/docs/playwright/html/hover-popups-dismiss.qmd b/tests/docs/playwright/html/hover-popups-dismiss.qmd new file mode 100644 index 0000000000..9a080a51e9 --- /dev/null +++ b/tests/docs/playwright/html/hover-popups-dismiss.qmd @@ -0,0 +1,38 @@ +--- +title: "Hover popups can be dismissed" +code-annotations: hover +code-copy: false +references: + - id: knuth1984 + type: book + author: + - family: Knuth + given: Donald E. + title: The TeXbook + issued: 1984 +--- + +## Popups + +A footnote.[^1] A citation [@knuth1984]. A figure, @fig-square. + +@sec-target has a link. + +[Next link](https://example.com/after). + +[^1]: The footnote text. + +::: {#fig-square} +Figure content. + +A figure caption. +::: + +```python +x = 1 # <1> +``` +1. The annotation text. + +## Target section {#sec-target} + +This paragraph has [a link inside the popup](https://example.com/inside). diff --git a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts new file mode 100644 index 0000000000..399df6a68d --- /dev/null +++ b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts @@ -0,0 +1,116 @@ +import { expect, Locator, Page, test } from "@playwright/test"; +import { getUrl } from "../src/utils"; + +// Regression tests for #15080. Hover popups for footnotes, citations, +// cross-references, and code annotations (code-annotations: hover) must +// close on Esc (WCAG 1.4.13, dismissible), and an interactive popup must +// close once focus leaves both its trigger and the popup (WCAG 2.4.11). + +const url = "html/hover-popups-dismiss.html"; + +// WebKit follows Safari's default of skipping links and buttons on Tab; +// Option+Tab visits every focusable element. +function tabKey(browserName: string) { + return browserName === "webkit" ? "Alt+Tab" : "Tab"; +} + +async function tabUntilFocused( + page: Page, + browserName: string, + target: Locator, + maxTabs = 25, +): Promise { + for (let i = 0; i < maxTabs; i++) { + await page.keyboard.press(tabKey(browserName)); + if (await target.evaluate((el) => el === document.activeElement)) { + return true; + } + } + return false; +} + +const triggers = [ + { name: "footnote", selector: 'a[role="doc-noteref"]', text: "footnote text" }, + { name: "citation", selector: 'a[role="doc-biblioref"]', text: "TeXbook" }, + { name: "figure cross-reference", selector: 'a.quarto-xref[href="#fig-square"]', text: "caption" }, + { name: "section cross-reference", selector: 'a.quarto-xref[href="#sec-target"]', text: "link inside" }, + { name: "code annotation", selector: "button.code-annotation-anchor", text: "annotation text" }, +]; + +test.describe("Esc closes hover popups", () => { + for (const { name, selector, text } of triggers) { + test(`${name} popup opened by keyboard focus`, async ({ page, browserName }) => { + await page.goto(getUrl(url), { waitUntil: "load" }); + const trigger = page.locator(selector); + const popup = page.locator(".tippy-box", { hasText: text }); + + expect(await tabUntilFocused(page, browserName, trigger)).toBe(true); + await expect(popup).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(popup).toBeHidden(); + await expect(trigger).toBeFocused(); + }); + + test(`${name} popup opened by pointer hover`, async ({ page }) => { + await page.goto(getUrl(url), { waitUntil: "load" }); + const trigger = page.locator(selector); + const popup = page.locator(".tippy-box", { hasText: text }); + + await trigger.hover(); + await expect(popup).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(popup).toBeHidden(); + }); + } +}); + +test.describe("interactive popup and focus", () => { + const xref = 'a.quarto-xref[href="#sec-target"]'; + + test("Esc inside the popup closes it and returns focus to the trigger", async ({ page, browserName }) => { + await page.goto(getUrl(url), { waitUntil: "load" }); + const trigger = page.locator(xref); + const popup = page.locator(".tippy-box", { hasText: "link inside" }); + const inner = popup.getByRole("link", { name: "a link inside the popup" }); + + expect(await tabUntilFocused(page, browserName, trigger)).toBe(true); + await expect(popup).toBeVisible(); + expect(await tabUntilFocused(page, browserName, inner, 5)).toBe(true); + + await page.keyboard.press("Escape"); + await expect(popup).toBeHidden(); + await expect(trigger).toBeFocused(); + }); + + test("popup closes when focus leaves trigger and popup", async ({ page, browserName }) => { + // Narrow viewport: the open popup would otherwise cover the next link. + await page.setViewportSize({ width: 390, height: 800 }); + await page.goto(getUrl(url), { waitUntil: "load" }); + const trigger = page.locator(xref); + const popup = page.locator(".tippy-box", { hasText: "link inside" }); + const next = page.getByRole("link", { name: "Next link" }); + + expect(await tabUntilFocused(page, browserName, trigger)).toBe(true); + await expect(popup).toBeVisible(); + expect(await tabUntilFocused(page, browserName, next, 10)).toBe(true); + + await expect(popup).toBeHidden(); + }); + + test("popup stays open when focus moves from the popup back to the trigger", async ({ page, browserName }) => { + await page.goto(getUrl(url), { waitUntil: "load" }); + const trigger = page.locator(xref); + const popup = page.locator(".tippy-box", { hasText: "link inside" }); + + expect(await tabUntilFocused(page, browserName, trigger)).toBe(true); + await expect(popup).toBeVisible(); + await page.keyboard.press(tabKey(browserName)); + await expect(popup.locator(":focus")).toHaveCount(1); + + await page.keyboard.press(browserName === "webkit" ? "Alt+Shift+Tab" : "Shift+Tab"); + await expect(trigger).toBeFocused(); + await expect(popup).toBeVisible(); + }); +}); From c1bc04527d4ef7d7ac7e7a29d99bbaa9c1c9e678 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 16 Oct 2026 16:21:15 +0200 Subject: [PATCH 2/8] Add select-mode and revealjs cases to popup dismissal tests 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. --- .../html/hover-popups-dismiss-select.qmd | 10 +++++ .../revealjs/hover-popups-dismiss.qmd | 16 +++++++ .../tests/html-hover-popups-dismiss.spec.ts | 44 +++++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 tests/docs/playwright/html/hover-popups-dismiss-select.qmd create mode 100644 tests/docs/playwright/revealjs/hover-popups-dismiss.qmd diff --git a/tests/docs/playwright/html/hover-popups-dismiss-select.qmd b/tests/docs/playwright/html/hover-popups-dismiss-select.qmd new file mode 100644 index 0000000000..7736274bda --- /dev/null +++ b/tests/docs/playwright/html/hover-popups-dismiss-select.qmd @@ -0,0 +1,10 @@ +--- +title: "Code annotation popups can be dismissed (select)" +code-annotations: select +code-copy: false +--- + +```python +x = 1 # <1> +``` +1. The annotation text. diff --git a/tests/docs/playwright/revealjs/hover-popups-dismiss.qmd b/tests/docs/playwright/revealjs/hover-popups-dismiss.qmd new file mode 100644 index 0000000000..86d8d99c8f --- /dev/null +++ b/tests/docs/playwright/revealjs/hover-popups-dismiss.qmd @@ -0,0 +1,16 @@ +--- +title: "Hover popups can be dismissed" +format: revealjs +references: + - id: knuth1984 + type: book + author: + - family: Knuth + given: Donald E. + title: The TeXbook + issued: 1984 +--- + +## Citation + +A citation [@knuth1984]. diff --git a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts index 399df6a68d..6932a36fdc 100644 --- a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts +++ b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts @@ -114,3 +114,47 @@ test.describe("interactive popup and focus", () => { await expect(popup).toBeVisible(); }); }); + +test.describe("code-annotations: select", () => { + const selectUrl = "html/hover-popups-dismiss-select.html"; + const selector = "button.code-annotation-anchor"; + + test("Esc closes a popup opened by click", async ({ page }) => { + await page.goto(getUrl(selectUrl), { waitUntil: "load" }); + const trigger = page.locator(selector); + const popup = page.locator(".tippy-box", { hasText: "annotation text" }); + + await trigger.click(); + await expect(popup).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(popup).toBeHidden(); + }); + + test("Esc closes a popup opened from the keyboard", async ({ page, browserName }) => { + await page.goto(getUrl(selectUrl), { waitUntil: "load" }); + const trigger = page.locator(selector); + const popup = page.locator(".tippy-box", { hasText: "annotation text" }); + + expect(await tabUntilFocused(page, browserName, trigger)).toBe(true); + await page.keyboard.press("Enter"); + await expect(popup).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(popup).toBeHidden(); + await expect(trigger).toBeFocused(); + }); +}); + +test("revealjs: Esc closes a citation popup without opening the overview", async ({ page }) => { + await page.goto(getUrl("revealjs/hover-popups-dismiss.html#/citation"), { waitUntil: "load" }); + const trigger = page.locator('a[role="doc-biblioref"]'); + const popup = page.locator(".tippy-box", { hasText: "TeXbook" }); + + await trigger.hover(); + await expect(popup).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(popup).toBeHidden(); + await expect(page.locator(".reveal")).not.toHaveClass(/\boverview\b/); +}); From 4d9990a8d0c370bf3b159444b6757b8ef6159640 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 16 Oct 2026 16:21:42 +0200 Subject: [PATCH 3/8] Close hover popups on Esc and when focus leaves them 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). --- news/changelog-1.11.md | 1 + .../html/templates/quarto-html-after-body.ejs | 47 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index e6001de649..6461a3d6ce 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -33,6 +33,7 @@ All changes included in 1.11: - ([#15019](https://github.com/quarto-dev/quarto-cli/issues/15019)): Fix screen reader announcements in `revealjs` presentations including incremental content before it is revealed and repeating slides and fragments when browser history is enabled. (author: @jooyoungseo) - ([#15030](https://github.com/quarto-dev/quarto-cli/pull/15030)): Fix `axe` checks in `revealjs` presentations hiding the current slide from screen readers when moving to another slide while the scan runs. - ([#15033](https://github.com/quarto-dev/quarto-cli/issues/15033)): Fix screen reader announcements in `revealjs` presentations reading the CSS inside Mermaid diagrams, omitting image alternative text, and reading the contents of `role="img"` graphics instead of their accessible names. (author: @jooyoungseo) +- ([#15080](https://github.com/quarto-dev/quarto-cli/issues/15080)): Fix footnote, citation, cross-reference and code annotation popups not closing with Esc, and staying open over the next focused element after keyboard focus leaves them. ## Formats diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index bab292f8a0..00d0453ec6 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -264,6 +264,51 @@ <% if (tippy) { %> + // Esc closes the popup (WCAG 1.4.13) and focus leaving both trigger and + // popup closes it (WCAG 2.4.11); tippy handles neither for interactive popups. + const hideOnEscAndFocusOut = { + name: 'hideOnEscAndFocusOut', + defaultValue: true, + fn(instance) { + const dismiss = () => { + // A show scheduled by the trigger delay would otherwise reopen it + instance.clearDelayTimeouts(); + instance.hide(); + }; + const onKeyDown = (event) => { + if (event.key !== 'Escape') { + return; + } + // Keep Esc from also reaching page handlers (e.g. reveal.js overview) + event.stopPropagation(); + if (instance.popper.contains(window.document.activeElement)) { + // Disabled while refocusing so the focus trigger does not reopen it + instance.disable(); + instance.reference.focus(); + instance.enable(); + } + dismiss(); + }; + const onFocusOut = (event) => { + const target = event.relatedTarget; + if (target && !instance.reference.contains(target) && + !instance.popper.contains(target)) { + dismiss(); + } + }; + return { + onShow() { + window.document.addEventListener('keydown', onKeyDown, true); + instance.popper.addEventListener('focusout', onFocusOut); + }, + onHide() { + window.document.removeEventListener('keydown', onKeyDown, true); + instance.popper.removeEventListener('focusout', onFocusOut); + }, + }; + }, + }; + function tippyHover(el, contentFn, onTriggerFn, onUntriggerFn) { const config = { allowHTML: true, @@ -281,6 +326,7 @@ interactiveBorder: 10, theme: '<%= tippyOptions.theme %>', placement: 'bottom-start', + plugins: [hideOnEscAndFocusOut], }; if (contentFn) { config.content = contentFn; @@ -641,6 +687,7 @@ interactiveBorder: 10, theme: '<%= tippyOptions.theme %>', placement: 'right', + plugins: [hideOnEscAndFocusOut], <% if (codeAnnotations === "select") { %>positionFixed: true,<% } %> popperOptions: { modifiers: [ From 063bf4e11a0675bce8c82f6655ace1ba141c8b20 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 16 Oct 2026 16:44:22 +0200 Subject: [PATCH 4/8] Share keyboard Tab helpers across Playwright specs 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. --- .claude/rules/testing/playwright-tests.md | 2 ++ tests/integration/playwright/src/utils.ts | 28 +++++++++++++++++-- .../tests/dashboard-hash-navigation.spec.ts | 9 ++---- .../tests/dashboard-skip-link.spec.ts | 12 +++----- .../html-focus-indicator-bare-btn.spec.ts | 25 +++-------------- .../tests/html-hover-popups-dismiss.spec.ts | 27 ++---------------- .../tests/html-scrollable-regions.spec.ts | 26 ++--------------- .../tests/notebook-preview-back-link.spec.ts | 21 ++------------ .../playwright/tests/revealjs.spec.ts | 8 ++---- 9 files changed, 49 insertions(+), 109 deletions(-) diff --git a/.claude/rules/testing/playwright-tests.md b/.claude/rules/testing/playwright-tests.md index 6b7370f866..3af0d2dcea 100644 --- a/.claude/rules/testing/playwright-tests.md +++ b/.claude/rules/testing/playwright-tests.md @@ -147,6 +147,8 @@ From `src/utils.ts`: | `ojsRuns(page)` | Wait for OJS to finish | | `checkColor(element, prop, color)` | Verify CSS color | | `useDarkLightMode(mode)` | Set color scheme for test | +| `tabKey(browserName, shift?)` | Tab key for the browser (WebKit needs `Alt+Tab` to reach links and buttons) | +| `tabUntilFocused(page, browserName, target)` | Press Tab until `target` is focused; returns whether it got there | ## Environment Variables diff --git a/tests/integration/playwright/src/utils.ts b/tests/integration/playwright/src/utils.ts index a6fb513ac6..31d4b97140 100644 --- a/tests/integration/playwright/src/utils.ts +++ b/tests/integration/playwright/src/utils.ts @@ -1,4 +1,4 @@ -import { expect, Locator, PlaywrightTestOptions } from "@playwright/test"; +import { expect, Locator, Page, PlaywrightTestOptions } from "@playwright/test"; // Port of the fixture server that playwright.config.ts starts on tests/docs/playwright export const fixtureServerPort = process.env.QUARTO_PLAYWRIGHT_PORT ?? "8765"; @@ -254,4 +254,28 @@ export function useDarkLightMode(mode: 'dark' | 'light'): Partial { + for (let i = 0; i < maxTabs; i++) { + await page.keyboard.press(tabKey(browserName)); + if (await target.evaluate((el) => el === document.activeElement)) { + return true; + } + } + return false; +} diff --git a/tests/integration/playwright/tests/dashboard-hash-navigation.spec.ts b/tests/integration/playwright/tests/dashboard-hash-navigation.spec.ts index 6ccd61419c..634055f193 100644 --- a/tests/integration/playwright/tests/dashboard-hash-navigation.spec.ts +++ b/tests/integration/playwright/tests/dashboard-hash-navigation.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from '@playwright/test'; +import { tabKey } from '../src/utils'; const dashboard = '/dashboard/hash-navigation.html'; @@ -56,16 +57,12 @@ test('page navigation and history still work', async ({ page }) => { // https://github.com/quarto-dev/quarto-cli/issues/14819 test('landing on a page hash starts tabbing at the top of the document', async ({ page, browserName }) => { - // WebKit only tabs to links when the Alt modifier is held, which matches - // Safari's default "Press Tab to highlight each item on a webpage" setting - const tabKey = browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; - await page.goto(`${dashboard}#cool`); await expect(coolPane(page)).toHaveClass(/active/); // the browser would otherwise start sequential focus inside the target // pane, which puts the navbar out of reach of a forward Tab - await page.keyboard.press(tabKey); + await page.keyboard.press(tabKey(browserName)); const firstStop = await page.evaluate(() => ({ isBody: document.activeElement === document.body, insideAPage: document.activeElement?.closest('.dashboard-page') !== null, @@ -83,7 +80,7 @@ test('landing on a page hash starts tabbing at the top of the document', async ( () => document.activeElement?.id === 'tab-cool' ); if (!reachedNavbarTab) { - await page.keyboard.press(tabKey); + await page.keyboard.press(tabKey(browserName)); } } expect(reachedNavbarTab).toBe(true); diff --git a/tests/integration/playwright/tests/dashboard-skip-link.spec.ts b/tests/integration/playwright/tests/dashboard-skip-link.spec.ts index b2a65c770d..cc70d974a0 100644 --- a/tests/integration/playwright/tests/dashboard-skip-link.spec.ts +++ b/tests/integration/playwright/tests/dashboard-skip-link.spec.ts @@ -1,19 +1,15 @@ import { test, expect } from '@playwright/test'; +import { tabKey } from '../src/utils'; const dashboard = '/dashboard/hash-navigation.html'; const heatPane = (page) => page.locator('#heat.dashboard-page'); const coolPane = (page) => page.locator('#cool.dashboard-page'); -// WebKit only tabs to links when the Alt modifier is held, which matches -// Safari's default "Press Tab to highlight each item on a webpage" setting -const tabKeyFor = (browserName: string) => - browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; - // https://github.com/quarto-dev/quarto-cli/issues/14684 test('the skip link is the first tab stop and moves focus into the content', async ({ page, browserName }) => { await page.goto(dashboard); - await page.keyboard.press(tabKeyFor(browserName)); + await page.keyboard.press(tabKey(browserName)); await expect(page.locator('#quarto-skip-link')).toBeFocused(); await page.keyboard.press('Enter'); @@ -24,7 +20,7 @@ test('the skip link does not add a history entry', async ({ page, browserName }) await page.goto(dashboard); await expect(heatPane(page)).toHaveClass(/active/); - await page.keyboard.press(tabKeyFor(browserName)); + await page.keyboard.press(tabKey(browserName)); await page.keyboard.press('Enter'); await expect(page.locator('#quarto-document-content')).toBeFocused(); // a plain anchor jump would leave "#quarto-document-content" here @@ -42,7 +38,7 @@ test('the skip link keeps a page hash in the URL', async ({ page, browserName }) await page.goto(`${dashboard}#cool`); await expect(coolPane(page)).toHaveClass(/active/); - await page.keyboard.press(tabKeyFor(browserName)); + await page.keyboard.press(tabKey(browserName)); await expect(page.locator('#quarto-skip-link')).toBeFocused(); await page.keyboard.press('Enter'); diff --git a/tests/integration/playwright/tests/html-focus-indicator-bare-btn.spec.ts b/tests/integration/playwright/tests/html-focus-indicator-bare-btn.spec.ts index 87a4556042..6fe5964cef 100644 --- a/tests/integration/playwright/tests/html-focus-indicator-bare-btn.spec.ts +++ b/tests/integration/playwright/tests/html-focus-indicator-bare-btn.spec.ts @@ -1,5 +1,5 @@ -import { expect, Locator, Page, test } from "@playwright/test"; -import { getUrl } from "../src/utils"; +import { expect, test } from "@playwright/test"; +import { getUrl, tabUntilFocused } from "../src/utils"; // Regression tests for #14774. Quarto emits three buttons that carry the // Bootstrap `btn` class with no `btn-*` variant class: the code tools @@ -9,25 +9,8 @@ import { getUrl } from "../src/utils"; // buttons took keyboard focus with no visible indicator. Quarto restores // the browser's native ring with `outline: revert`. -// Move focus with real Tab presses so the button matches :focus-visible — -// the fix only applies to keyboard focus, and programmatic locator.focus() -// does not reliably match :focus-visible. WebKit follows Safari's default -// of skipping buttons on Tab; Option+Tab visits every focusable element. -async function tabUntilFocused( - page: Page, - browserName: string, - target: Locator, - maxTabs = 25, -): Promise { - const tabKey = browserName === "webkit" ? "Alt+Tab" : "Tab"; - for (let i = 0; i < maxTabs; i++) { - await page.keyboard.press(tabKey); - if (await target.evaluate((el) => el === document.activeElement)) { - return true; - } - } - return false; -} +// Focus moves with real Tab presses (tabUntilFocused) because the fix only +// applies to keyboard focus, i.e. to :focus-visible. test("code tools button shows a focus indicator on keyboard focus", async ({ page, diff --git a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts index 6932a36fdc..e434d509ad 100644 --- a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts +++ b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts @@ -1,5 +1,5 @@ -import { expect, Locator, Page, test } from "@playwright/test"; -import { getUrl } from "../src/utils"; +import { expect, test } from "@playwright/test"; +import { getUrl, tabKey, tabUntilFocused } from "../src/utils"; // Regression tests for #15080. Hover popups for footnotes, citations, // cross-references, and code annotations (code-annotations: hover) must @@ -8,27 +8,6 @@ import { getUrl } from "../src/utils"; const url = "html/hover-popups-dismiss.html"; -// WebKit follows Safari's default of skipping links and buttons on Tab; -// Option+Tab visits every focusable element. -function tabKey(browserName: string) { - return browserName === "webkit" ? "Alt+Tab" : "Tab"; -} - -async function tabUntilFocused( - page: Page, - browserName: string, - target: Locator, - maxTabs = 25, -): Promise { - for (let i = 0; i < maxTabs; i++) { - await page.keyboard.press(tabKey(browserName)); - if (await target.evaluate((el) => el === document.activeElement)) { - return true; - } - } - return false; -} - const triggers = [ { name: "footnote", selector: 'a[role="doc-noteref"]', text: "footnote text" }, { name: "citation", selector: 'a[role="doc-biblioref"]', text: "TeXbook" }, @@ -109,7 +88,7 @@ test.describe("interactive popup and focus", () => { await page.keyboard.press(tabKey(browserName)); await expect(popup.locator(":focus")).toHaveCount(1); - await page.keyboard.press(browserName === "webkit" ? "Alt+Shift+Tab" : "Shift+Tab"); + await page.keyboard.press(tabKey(browserName, true)); await expect(trigger).toBeFocused(); await expect(popup).toBeVisible(); }); diff --git a/tests/integration/playwright/tests/html-scrollable-regions.spec.ts b/tests/integration/playwright/tests/html-scrollable-regions.spec.ts index 03981450b9..389e0bfec9 100644 --- a/tests/integration/playwright/tests/html-scrollable-regions.spec.ts +++ b/tests/integration/playwright/tests/html-scrollable-regions.spec.ts @@ -1,5 +1,5 @@ -import { expect, Locator, Page, test } from "@playwright/test"; -import { getUrl } from "../src/utils"; +import { expect, test } from "@playwright/test"; +import { getUrl, tabKey, tabUntilFocused } from "../src/utils"; // Tests for #14378. The scrollable-regions runtime makes code blocks and // cell output keyboard-focusable (tabindex="0", role="group", localized @@ -7,26 +7,6 @@ import { getUrl } from "../src/utils"; // overflow, and removes the attributes when they stop. The fixture's wide // elements overflow a 390px viewport and fit a 1440px one. -// Move focus with real Tab presses — programmatic locator.focus() does not -// reliably match :focus-visible in any engine. WebKit follows Safari's -// default of skipping most elements on Tab; Option+Tab visits every -// focusable element. -async function tabUntilFocused( - page: Page, - browserName: string, - target: Locator, - maxTabs = 25, -): Promise { - const tabKey = browserName === "webkit" ? "Alt+Tab" : "Tab"; - for (let i = 0; i < maxTabs; i++) { - await page.keyboard.press(tabKey); - if (await target.evaluate((el) => el === document.activeElement)) { - return true; - } - } - return false; -} - test.describe("scrollable regions at a mobile viewport", () => { test.use({ viewport: { width: 390, height: 700 } }); @@ -208,7 +188,7 @@ test.describe("scrollable regions at a mobile viewport", () => { // Focus leaving is what releases it; without the focusout re-sync the // attributes would sit stale until some later, unrelated resize. - await page.keyboard.press(browserName === "webkit" ? "Alt+Tab" : "Tab"); + await page.keyboard.press(tabKey(browserName)); await expect(page.locator("[data-quarto-scrollable]")).toHaveCount(0); await expect(block).not.toHaveAttribute("tabindex"); await expect(block).not.toHaveAttribute("role"); diff --git a/tests/integration/playwright/tests/notebook-preview-back-link.spec.ts b/tests/integration/playwright/tests/notebook-preview-back-link.spec.ts index 916f578476..f300b449d5 100644 --- a/tests/integration/playwright/tests/notebook-preview-back-link.spec.ts +++ b/tests/integration/playwright/tests/notebook-preview-back-link.spec.ts @@ -1,22 +1,5 @@ -import { expect, Locator, Page, test } from "@playwright/test"; - -// WebKit follows Safari's default of skipping links on Tab; Option+Tab -// visits every focusable element. -async function tabUntilFocused( - page: Page, - browserName: string, - target: Locator, - maxTabs = 25, -): Promise { - const tabKey = browserName === "webkit" ? "Alt+Tab" : "Tab"; - for (let i = 0; i < maxTabs; i++) { - await page.keyboard.press(tabKey); - if (await target.evaluate((el) => el === document.activeElement)) { - return true; - } - } - return false; -} +import { expect, test } from "@playwright/test"; +import { tabUntilFocused } from "../src/utils"; // The notebook preview "Back to Article" control must be a keyboard-operable // link in both website and standalone renders (#15015). diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index 99cc07b782..8053a1951e 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -1,4 +1,5 @@ import { test, expect, Page } from '@playwright/test'; +import { tabKey } from '../src/utils'; test('logo and footer are correctly shown in default mode', async ({ page }) => { await page.goto('./revealjs/logo-footer.html#/slide-1'); @@ -201,11 +202,6 @@ test.describe('off-screen slides are not focusable (#14795)', () => { test.describe('the closed slide menu is out of the tab order', () => { const deck = './revealjs/menu-focus-order.html'; - // WebKit only tabs to links when the Alt modifier is held, which matches - // Safari's default "Press Tab to highlight each item on a webpage" setting - const tabKeyFor = (browserName: string) => - browserName === 'webkit' ? 'Alt+Tab' : 'Tab'; - const menu = (page: Page) => page.locator('nav.slide-menu'); test('Tab does not stop in the closed menu', async ({ page, browserName }) => { @@ -221,7 +217,7 @@ test.describe('the closed slide menu is out of the tab order', () => { // The closed menu comes right after its button in the document await page.locator('.slide-menu-button a').focus(); for (let i = 0; i < 3; i++) { - await page.keyboard.press(tabKeyFor(browserName)); + await page.keyboard.press(tabKey(browserName)); await expect(page.locator('nav.slide-menu:focus-within')).toHaveCount(0); } }); From 37af4ba319309dfcf370ac9738597dd205abfea8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 16 Oct 2026 17:00:04 +0200 Subject: [PATCH 5/8] Let an open revealjs slide menu receive Esc over a popup 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. --- .../html/templates/quarto-html-after-body.ejs | 7 +++++-- .../tests/html-hover-popups-dismiss.spec.ts | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index 00d0453ec6..8695d1df99 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -279,8 +279,11 @@ if (event.key !== 'Escape') { return; } - // Keep Esc from also reaching page handlers (e.g. reveal.js overview) - event.stopPropagation(); + // Keep Esc from also reaching page handlers (e.g. reveal.js overview), + // except an open reveal.js slide menu, which Esc should close too + if (!window.Reveal?.getPlugin?.('menu')?.isOpen()) { + event.stopPropagation(); + } if (instance.popper.contains(window.document.activeElement)) { // Disabled while refocusing so the focus trigger does not reopen it instance.disable(); diff --git a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts index e434d509ad..ff3bfa6336 100644 --- a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts +++ b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts @@ -137,3 +137,21 @@ test("revealjs: Esc closes a citation popup without opening the overview", async await expect(popup).toBeHidden(); await expect(page.locator(".reveal")).not.toHaveClass(/\boverview\b/); }); + +test("revealjs: Esc closes the slide menu opened over a citation popup", async ({ page, browserName }) => { + await page.goto(getUrl("revealjs/hover-popups-dismiss.html#/citation"), { waitUntil: "load" }); + const menu = page.locator("nav.slide-menu"); + const popup = page.locator(".tippy-box", { hasText: "TeXbook" }); + const trigger = page.locator('a[role="doc-biblioref"]'); + + // Keyboard focus keeps the popup open while the menu covers the trigger + expect(await tabUntilFocused(page, browserName, trigger)).toBe(true); + await expect(popup).toBeVisible(); + await page.keyboard.press("m"); + await expect(menu).not.toHaveAttribute("inert"); + await expect(popup).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(menu).toHaveAttribute("inert"); + await expect(page.locator(".reveal")).not.toHaveClass(/\boverview\b/); +}); From 95ed47d2361b87c272c6159a721a6d581a8db89f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 16 Oct 2026 17:18:52 +0200 Subject: [PATCH 6/8] Close select-mode annotation popups when focus leaves the trigger 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. --- .../html/templates/quarto-html-after-body.ejs | 3 +++ .../html/hover-popups-dismiss-select.qmd | 2 ++ .../tests/html-hover-popups-dismiss.spec.ts | 14 ++++++++++++++ 3 files changed, 19 insertions(+) diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index 8695d1df99..41a4e78c3d 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -302,10 +302,13 @@ return { onShow() { window.document.addEventListener('keydown', onKeyDown, true); + // On the trigger too: a click trigger (code-annotations: select) has no blur handling + instance.reference.addEventListener('focusout', onFocusOut); instance.popper.addEventListener('focusout', onFocusOut); }, onHide() { window.document.removeEventListener('keydown', onKeyDown, true); + instance.reference.removeEventListener('focusout', onFocusOut); instance.popper.removeEventListener('focusout', onFocusOut); }, }; diff --git a/tests/docs/playwright/html/hover-popups-dismiss-select.qmd b/tests/docs/playwright/html/hover-popups-dismiss-select.qmd index 7736274bda..c339d3d99d 100644 --- a/tests/docs/playwright/html/hover-popups-dismiss-select.qmd +++ b/tests/docs/playwright/html/hover-popups-dismiss-select.qmd @@ -8,3 +8,5 @@ code-copy: false x = 1 # <1> ``` 1. The annotation text. + +[Next link](https://example.com/after). diff --git a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts index ff3bfa6336..5249fa15b4 100644 --- a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts +++ b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts @@ -123,6 +123,20 @@ test.describe("code-annotations: select", () => { await expect(popup).toBeHidden(); await expect(trigger).toBeFocused(); }); + + test("popup closes when Tab moves focus from the trigger to the next link", async ({ page, browserName }) => { + await page.goto(getUrl(selectUrl), { waitUntil: "load" }); + const trigger = page.locator(selector); + const popup = page.locator(".tippy-box", { hasText: "annotation text" }); + const next = page.getByRole("link", { name: "Next link" }); + + expect(await tabUntilFocused(page, browserName, trigger)).toBe(true); + await page.keyboard.press("Enter"); + await expect(popup).toBeVisible(); + + expect(await tabUntilFocused(page, browserName, next, 5)).toBe(true); + await expect(popup).toBeHidden(); + }); }); test("revealjs: Esc closes a citation popup without opening the overview", async ({ page }) => { From 16c228fe688870a1f516a78a9d44b1eb811f7ce4 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 16 Oct 2026 17:39:50 +0200 Subject: [PATCH 7/8] Keep popup Esc from reaching a slide menu that ignores the keyboard 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). --- .../html/templates/quarto-html-after-body.ejs | 7 +++++-- .../hover-popups-dismiss-menu-no-keyboard.qmd | 20 +++++++++++++++++++ .../tests/html-hover-popups-dismiss.spec.ts | 15 ++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 tests/docs/playwright/revealjs/hover-popups-dismiss-menu-no-keyboard.qmd diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index 41a4e78c3d..b71720f65e 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -280,8 +280,11 @@ return; } // Keep Esc from also reaching page handlers (e.g. reveal.js overview), - // except an open reveal.js slide menu, which Esc should close too - if (!window.Reveal?.getPlugin?.('menu')?.isOpen()) { + // except an open reveal.js slide menu, which Esc should close too. + // The menu handles keys only when its keyboard option is on. + const menuHandlesEsc = window.Reveal?.getPlugin?.('menu')?.isOpen() && + window.Reveal.getConfig().menu?.keyboard !== false; + if (!menuHandlesEsc) { event.stopPropagation(); } if (instance.popper.contains(window.document.activeElement)) { diff --git a/tests/docs/playwright/revealjs/hover-popups-dismiss-menu-no-keyboard.qmd b/tests/docs/playwright/revealjs/hover-popups-dismiss-menu-no-keyboard.qmd new file mode 100644 index 0000000000..670af5b050 --- /dev/null +++ b/tests/docs/playwright/revealjs/hover-popups-dismiss-menu-no-keyboard.qmd @@ -0,0 +1,20 @@ +--- +title: "Hover popups can be dismissed with a slide menu that ignores the keyboard" +format: + revealjs: + menu: + keyboard: false + open-on-init: true +references: + - id: knuth1984 + type: book + author: + - family: Knuth + given: Donald E. + title: The TeXbook + issued: 1984 +--- + +## Citation + +A citation [@knuth1984]. diff --git a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts index 5249fa15b4..9ff1ccbef8 100644 --- a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts +++ b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts @@ -169,3 +169,18 @@ test("revealjs: Esc closes the slide menu opened over a citation popup", async ( await expect(menu).toHaveAttribute("inert"); await expect(page.locator(".reveal")).not.toHaveClass(/\boverview\b/); }); + +test("revealjs: Esc closes a citation popup without opening the overview when the open menu ignores the keyboard", async ({ page, browserName }) => { + await page.goto(getUrl("revealjs/hover-popups-dismiss-menu-no-keyboard.html#/citation"), { waitUntil: "load" }); + const menu = page.locator("nav.slide-menu"); + const popup = page.locator(".tippy-box", { hasText: "TeXbook" }); + const trigger = page.locator('a[role="doc-biblioref"]'); + + await expect(menu).not.toHaveAttribute("inert"); + expect(await tabUntilFocused(page, browserName, trigger)).toBe(true); + await expect(popup).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(popup).toBeHidden(); + await expect(page.locator(".reveal")).not.toHaveClass(/\boverview\b/); +}); From 4e7e28c8738dc11438aa15a6584c0d2928d04ed8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 16 Oct 2026 20:53:36 +0200 Subject: [PATCH 8/8] Cancel a pending popup show when focus leaves the trigger 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. --- .../html/templates/quarto-html-after-body.ejs | 10 +++++++--- .../tests/html-hover-popups-dismiss.spec.ts | 15 +++++++++++++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/resources/formats/html/templates/quarto-html-after-body.ejs b/src/resources/formats/html/templates/quarto-html-after-body.ejs index b71720f65e..e1748528f6 100644 --- a/src/resources/formats/html/templates/quarto-html-after-body.ejs +++ b/src/resources/formats/html/templates/quarto-html-after-body.ejs @@ -302,18 +302,22 @@ dismiss(); } }; + // On the trigger too: a click trigger (code-annotations: select) has no + // blur handling. Attached for the instance's lifetime so leaving the + // trigger also cancels a show still pending on the trigger delay. + instance.reference.addEventListener('focusout', onFocusOut); return { onShow() { window.document.addEventListener('keydown', onKeyDown, true); - // On the trigger too: a click trigger (code-annotations: select) has no blur handling - instance.reference.addEventListener('focusout', onFocusOut); instance.popper.addEventListener('focusout', onFocusOut); }, onHide() { window.document.removeEventListener('keydown', onKeyDown, true); - instance.reference.removeEventListener('focusout', onFocusOut); instance.popper.removeEventListener('focusout', onFocusOut); }, + onDestroy() { + instance.reference.removeEventListener('focusout', onFocusOut); + }, }; }, }; diff --git a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts index 9ff1ccbef8..0b41541a3c 100644 --- a/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts +++ b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts @@ -137,6 +137,21 @@ test.describe("code-annotations: select", () => { expect(await tabUntilFocused(page, browserName, next, 5)).toBe(true); await expect(popup).toBeHidden(); }); + + test("popup does not open when Tab leaves the trigger during the show delay", async ({ page, browserName }) => { + await page.goto(getUrl(selectUrl), { waitUntil: "load" }); + const trigger = page.locator(selector); + const popup = page.locator(".tippy-box", { hasText: "annotation text" }); + + expect(await tabUntilFocused(page, browserName, trigger)).toBe(true); + await page.keyboard.press("Enter"); + await page.keyboard.press(tabKey(browserName)); + await expect(trigger).not.toBeFocused(); + + // Outlast the 50 ms show delay before asserting the popup stays closed + await page.waitForTimeout(500); + await expect(popup).toBeHidden(); + }); }); test("revealjs: Esc closes a citation popup without opening the overview", async ({ page }) => {