Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .claude/rules/testing/playwright-tests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
60 changes: 60 additions & 0 deletions src/resources/formats/html/templates/quarto-html-after-body.ejs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -281,6 +339,7 @@
interactiveBorder: 10,
theme: '<%= tippyOptions.theme %>',
placement: 'bottom-start',
plugins: [hideOnEscAndFocusOut],
};
if (contentFn) {
config.content = contentFn;
Expand Down Expand Up @@ -641,6 +700,7 @@
interactiveBorder: 10,
theme: '<%= tippyOptions.theme %>',
placement: 'right',
plugins: [hideOnEscAndFocusOut],
<% if (codeAnnotations === "select") { %>positionFixed: true,<% } %>
popperOptions: {
modifiers: [
Expand Down
12 changes: 12 additions & 0 deletions tests/docs/playwright/html/hover-popups-dismiss-select.qmd
Original file line number Diff line number Diff line change
@@ -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).
38 changes: 38 additions & 0 deletions tests/docs/playwright/html/hover-popups-dismiss.qmd
Original file line number Diff line number Diff line change
@@ -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).
Original file line number Diff line number Diff line change
@@ -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].
16 changes: 16 additions & 0 deletions tests/docs/playwright/revealjs/hover-popups-dismiss.qmd
Original file line number Diff line number Diff line change
@@ -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].
28 changes: 26 additions & 2 deletions tests/integration/playwright/src/utils.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -254,4 +254,28 @@ export function useDarkLightMode(mode: 'dark' | 'light'): Partial<PlaywrightTest
return {
colorScheme: mode
};
}
}
// WebKit follows Safari's default "Press Tab to highlight each item on a
// webpage" setting and skips links and buttons on Tab; Option+Tab visits
// every focusable element.
export function tabKey(browserName: string, shift = false): string {
const key = shift ? "Shift+Tab" : "Tab";
return browserName === "webkit" ? `Alt+${key}` : key;
}

// Presses Tab until `target` holds focus. Real key presses, unlike
// locator.focus(), reliably make the target match :focus-visible.
export async function tabUntilFocused(
page: Page,
browserName: string,
target: Locator,
maxTabs = 25,
): Promise<boolean> {
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;
}
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect } from '@playwright/test';
import { tabKey } from '../src/utils';

const dashboard = '/dashboard/hash-navigation.html';

Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
12 changes: 4 additions & 8 deletions tests/integration/playwright/tests/dashboard-skip-link.spec.ts
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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
Expand All @@ -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');

Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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<boolean> {
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,
Expand Down
Loading
Loading