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/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..e1748528f6 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,64 @@ <% 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), + // 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)) { + // 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(); + } + }; + // 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); + instance.popper.addEventListener('focusout', onFocusOut); + }, + onHide() { + window.document.removeEventListener('keydown', onKeyDown, true); + instance.popper.removeEventListener('focusout', onFocusOut); + }, + onDestroy() { + instance.reference.removeEventListener('focusout', onFocusOut); + }, + }; + }, + }; + function tippyHover(el, contentFn, onTriggerFn, onUntriggerFn) { const config = { allowHTML: true, @@ -281,6 +339,7 @@ interactiveBorder: 10, theme: '<%= tippyOptions.theme %>', placement: 'bottom-start', + plugins: [hideOnEscAndFocusOut], }; if (contentFn) { config.content = contentFn; @@ -641,6 +700,7 @@ interactiveBorder: 10, theme: '<%= tippyOptions.theme %>', placement: 'right', + plugins: [hideOnEscAndFocusOut], <% if (codeAnnotations === "select") { %>positionFixed: true,<% } %> popperOptions: { modifiers: [ 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..c339d3d99d --- /dev/null +++ b/tests/docs/playwright/html/hover-popups-dismiss-select.qmd @@ -0,0 +1,12 @@ +--- +title: "Code annotation popups can be dismissed (select)" +code-annotations: select +code-copy: false +--- + +```python +x = 1 # <1> +``` +1. The annotation text. + +[Next link](https://example.com/after). 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/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/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/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 new file mode 100644 index 0000000000..0b41541a3c --- /dev/null +++ b/tests/integration/playwright/tests/html-hover-popups-dismiss.spec.ts @@ -0,0 +1,201 @@ +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 +// 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"; + +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(tabKey(browserName, true)); + await expect(trigger).toBeFocused(); + 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("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("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 }) => { + 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/); +}); + +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/); +}); + +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/); +}); 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); } });