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
1 change: 1 addition & 0 deletions news/changelog-1.11.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ All changes included in 1.11:

## Accessibility

- ([#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.
- ([#730](https://github.com/quarto-dev/quarto-cli/issues/730)): Fix keyboard navigation of tabsets in HTML documents without Bootstrap (`theme: none`): Tab now moves from the selected tab into its panel instead of selecting the next tab, and the arrow keys switch tabs.
- ([#13463](https://github.com/quarto-dev/quarto-cli/issues/13463)): The dark/light mode toggle is now a switch (`button` with `role="switch"`, `aria-checked`, and a localized `aria-label`) instead of a link.
- ([#14615](https://github.com/quarto-dev/quarto-cli/issues/14615)): Fix invalid `role="menu"` on the website navbar's collapse toggle button, flagged by axe-core (`aria-allowed-role`) and WAVE (`aria-role-mismatch`).
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
diff --git a/js/controllers/location.js b/js/controllers/location.js
index 2299d47..b0dbc01 100644
--- a/js/controllers/location.js
+++ b/js/controllers/location.js
@@ -105,8 +105,14 @@ export default class Location {
const newIndices = this.getIndicesFromHash();

if( newIndices ) {
- if( ( newIndices.h !== currentIndices.h || newIndices.v !== currentIndices.v || newIndices.f !== undefined ) ) {
- this.Reveal.slide( newIndices.h, newIndices.v, newIndices.f );
+ const fragmentInURL = this.Reveal.getConfig().fragmentInURL;
+ const newFragmentIndex = newIndices.f === undefined && fragmentInURL ? -1 : newIndices.f;
+ const fragmentChanged = newFragmentIndex !== undefined && newFragmentIndex !== ( currentIndices.f ?? -1 );
+
+ if( newIndices.h !== currentIndices.h ||
+ ( newIndices.v ?? 0 ) !== ( currentIndices.v ?? 0 ) ||
+ fragmentChanged ) {
+ this.Reveal.slide( newIndices.h, newIndices.v, newFragmentIndex );
}
}
// If no new indices are available, we're trying to navigate to
diff --git a/js/reveal.js b/js/reveal.js
index 927b0a1..52b19df 100644
--- a/js/reveal.js
+++ b/js/reveal.js
@@ -381,7 +381,7 @@ export default function( revealElement, options ) {
* that can be announced to a screen reader. Hidden
* elements are excluded.
*/
- function getStatusText( node ) {
+ function getStatusText( node, rootNode = node ) {

let text = '';

@@ -394,10 +394,33 @@ export default function( revealElement, options ) {

let isAriaHidden = node.getAttribute( 'aria-hidden' );
let isDisplayHidden = window.getComputedStyle( node )['display'] === 'none';
- if( isAriaHidden !== 'true' && !isDisplayHidden ) {
+ let isFragmentHidden = false;
+
+ // Fragment visibility is class-based since computed styles may still
+ // reflect an in-progress transition when an announcement is made.
+ // The root is exempt so a fragment can announce itself as it changes.
+ if( node !== rootNode && node.classList.contains( 'fragment' ) && !node.classList.contains( 'custom' ) ) {
+ const isVisible = node.classList.contains( 'visible' );
+ const isCurrent = node.classList.contains( 'current-fragment' );
+ const isCurrentOnly = node.classList.contains( 'current-visible' ) || node.classList.contains( 'fade-in-then-out' );
+ const isInitiallyVisible = ['grow', 'shrink', 'strike', 'semi-fade-out'].some( style => node.classList.contains( style ) ) ||
+ Array.from( node.classList ).some( style => /^highlight-(current-)?(red|green|blue)$/.test( style ) );
+
+ if( isCurrentOnly ) {
+ isFragmentHidden = !isCurrent;
+ }
+ else if( node.classList.contains( 'fade-out' ) ) {
+ isFragmentHidden = isVisible;
+ }
+ else {
+ isFragmentHidden = !isVisible && !isInitiallyVisible;
+ }
+ }
+
+ if( isAriaHidden !== 'true' && !isDisplayHidden && !isFragmentHidden ) {

Array.from( node.childNodes ).forEach( child => {
- text += getStatusText( child );
+ text += getStatusText( child, rootNode );
} );

}
18 changes: 17 additions & 1 deletion package/src/common/update-html-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,23 @@ export async function updateHtmlDependencies(config: Configuration) {
"hakimel/reveal.js",
"REVEAL_JS",
workingDir,
(dir: string, version: string) => {
async (dir: string, version: string) => {
// Backport hakimel/reveal.js#3945 while Quarto bundles reveal.js 5.1.0.
// Remove the source patch and rebuild step when upgrading to a fixed release.
const sourceDir = join(dir, `reveal.js-${version}`);
await runCmd("git", [
"-C", sourceDir, "apply", "--ignore-space-change",
join(patchesDir, "revealjs-0001-screen-reader-announcements.patch"),
]);
// Skip install scripts: rebuilding the bundles does not need Puppeteer.
await runCmd(config.os === "windows" ? "npm.cmd" : "npm", [
"--prefix", sourceDir, "ci", "--ignore-scripts",
]);
await runCmd("node", [
join(sourceDir, "node_modules", "gulp", "bin", "gulp.js"),
"--cwd", sourceDir, "js",
]);

// Copy the desired resource files
info("Copying reveal.js resources' directory");
if (existsSync(revealJs)) {
Expand Down
2 changes: 1 addition & 1 deletion src/resources/formats/revealjs/reveal/dist/reveal.esm.js

Large diffs are not rendered by default.

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/resources/formats/revealjs/reveal/dist/reveal.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion src/resources/formats/revealjs/reveal/dist/reveal.js.map

Large diffs are not rendered by default.

27 changes: 27 additions & 0 deletions tests/docs/playwright/revealjs/announcements-fragment-in-url.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
pagetitle: Reveal.js announcements with fragment URLs
format:
revealjs:
history: true
fragment-in-url: true
---

## Intro {#intro}

## Steps {#steps}

Visible lead

<div class="fragment grow">Grow starts visible</div>

<div class="fragment fade-out">Fade out starts visible</div>

<div class="fragment">Outer step <span class="fragment">Nested step</span></div>

Before pause

. . .

After pause

## History target {#history-target}
26 changes: 26 additions & 0 deletions tests/docs/playwright/revealjs/announcements.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
pagetitle: Reveal.js announcements
format:
revealjs:
history: true
---

## Intro {#intro}

## Steps {#steps}

Visible lead

<div class="fragment grow">Grow starts visible</div>

<div class="fragment fade-out">Fade out starts visible</div>

<div class="fragment">Outer step <span class="fragment">Nested step</span></div>

Before pause

. . .

After pause

## History target {#history-target}
177 changes: 177 additions & 0 deletions tests/integration/playwright/tests/revealjs-announcements.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { expect, Page, test } from '@playwright/test';

const normalizedAnnouncements = (page: Page) =>
page.evaluate(() => (window as any).__revealAnnouncements as string[]);

async function installAnnouncementObserver(page: Page) {
await page.addInitScript(() => {
const announcements: string[] = [];
(window as any).__revealAnnouncements = announcements;

new MutationObserver((records) => {
for (const record of records) {
if (
record.target instanceof Element &&
record.target.matches('.reveal .aria-status')
) {
for (const node of record.addedNodes) {
const text = node.textContent?.replace(/\s+/g, ' ').trim();
if (text) announcements.push(text);
}
}
}
}).observe(document, { childList: true, subtree: true });
});
}

async function expectAnnouncements(page: Page, expected: string[]) {
await expect.poll(async () => (await normalizedAnnouncements(page)).length)
.toBe(expected.length);
// A history-driven hashchange arrives after the live region mutation. Give it
// a turn so a repeated announcement cannot pass as a transient match.
await page.waitForTimeout(100);
expect(await normalizedAnnouncements(page)).toEqual(expected);
}

const fixtures = [
{
name: 'fragment-in-url off',
url: './revealjs/announcements.html',
fragmentHash: '#/steps',
},
{
name: 'fragment-in-url on',
url: './revealjs/announcements-fragment-in-url.html',
fragmentHash: '#/steps/0',
},
];

for (const fixture of fixtures) {
test(`${fixture.name}: announces only visible content once`, async ({ page }) => {
await installAnnouncementObserver(page);
await page.goto(fixture.url);
await expect(page.locator('.reveal.ready')).toBeAttached();
await expect(page).toHaveURL(/#\/intro$/);

const expected = ['Intro'];
await expectAnnouncements(page, expected);

await page.keyboard.press('ArrowRight');
await expect(page.locator('section.present')).toHaveId('steps');
await expect(page).toHaveURL(/#\/steps$/);
expected.push(
'Steps Visible lead Grow starts visible Fade out starts visible Before pause',
);
await expectAnnouncements(page, expected);

const steps = [
['Grow starts visible', 0],
['Fade out starts visible', 1],
['Outer step', 2],
['Nested step', 3],
['After pause', 4],
] as const;

for (const [announcement, index] of steps) {
await page.keyboard.press('Space');
const hash = fixture.fragmentHash.replace('/0', `/${index}`);
await expect(page).toHaveURL(new RegExp(`${hash.replace('/', '\\/')}$`));
expected.push(announcement);
await expectAnnouncements(page, expected);
}
});
}

test('fragment URL history distinguishes explicit indices from its boundary', async ({ page }) => {
await installAnnouncementObserver(page);
await page.goto('./revealjs/announcements-fragment-in-url.html');
await expect(page.locator('.reveal.ready')).toBeAttached();

const expected = ['Intro'];
await expectAnnouncements(page, expected);

await page.keyboard.press('ArrowRight');
await expect(page).toHaveURL(/#\/steps$/);
expected.push(
'Steps Visible lead Grow starts visible Fade out starts visible Before pause',
);
await expectAnnouncements(page, expected);

await page.keyboard.press('Space');
await expect(page).toHaveURL(/#\/steps\/0$/);
expected.push('Grow starts visible');
await expectAnnouncements(page, expected);

await page.keyboard.press('Space');
await expect(page).toHaveURL(/#\/steps\/1$/);
expected.push('Fade out starts visible');
await expectAnnouncements(page, expected);

await page.goBack();
await expect(page).toHaveURL(/#\/steps\/0$/);
await expect.poll(() =>
page.evaluate(() => (window as any).Reveal.getIndices().f)
).toBe(0);
// With fragment URLs enabled, the history entry before the first fragment
// is the explicit boundary state: no fragment is visible.
await page.goBack();
await expect(page).toHaveURL(/#\/steps$/);
await expect.poll(() =>
page.evaluate(() => (window as any).Reveal.getIndices().f)
).toBe(-1);
await expect(page.locator('#steps .fragment.visible')).toHaveCount(0);

await page.goForward();
await expect(page).toHaveURL(/#\/steps\/0$/);
await expect.poll(() =>
page.evaluate(() => (window as any).Reveal.getIndices().f)
).toBe(0);
await expect(page.locator('#steps .fragment.visible')).toHaveCount(1);

// A hashchange for the current explicit fragment is a no-op and must not
// add another live-region announcement.
await page.waitForTimeout(100);
const atFirstFragment = await normalizedAnnouncements(page);
await page.evaluate(() => window.dispatchEvent(new HashChangeEvent('hashchange')));
await expectAnnouncements(page, atFirstFragment);

await page.goForward();
await expect(page).toHaveURL(/#\/steps\/1$/);
await expect.poll(() =>
page.evaluate(() => (window as any).Reveal.getIndices().f)
).toBe(1);
});

test('real hash navigation and browser history still change slides', async ({ page }) => {
await installAnnouncementObserver(page);
await page.goto('./revealjs/announcements.html');
await expect(page.locator('.reveal.ready')).toBeAttached();
await expectAnnouncements(page, ['Intro']);

await page.evaluate(() => { window.location.hash = '#/history-target'; });
await expect(page.locator('section.present')).toHaveId('history-target');
await expectAnnouncements(page, ['Intro', 'History target']);

await page.evaluate(() => { window.location.hash = '#/intro'; });
await expect(page.locator('section.present')).toHaveId('intro');
await expectAnnouncements(page, ['Intro', 'History target', 'Intro']);

await page.goBack();
await expect(page.locator('section.present')).toHaveId('history-target');
await expectAnnouncements(page, [
'Intro',
'History target',
'Intro',
'History target',
]);

await page.goForward();
await expect(page.locator('section.present')).toHaveId('intro');
await expectAnnouncements(page, [
'Intro',
'History target',
'Intro',
'History target',
'Intro',
]);
});
Loading