Skip to content
Merged
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 @@ -42,6 +42,7 @@ All changes included in 1.11:
- ([#15079](https://github.com/quarto-dev/quarto-cli/issues/15079)): Fix keyboard focus in `revealjs` presentations moving to the page when coming back to the presentation's browser tab, which took readers off the link or chart they were on and made screen readers stop passing the arrow keys to the presentation. (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.
- ([#15129](https://github.com/quarto-dev/quarto-cli/issues/15129)): Fix default listings rendering an empty, focusable metadata link for posts with no date, author or other listed field to show, flagged by axe-core (`link-name`).
- ([#15159](https://github.com/quarto-dev/quarto-cli/issues/15159), [#15160](https://github.com/quarto-dev/quarto-cli/issues/15160)): Fix the `revealjs` speaker view's Layout selector having no accessible name or visible keyboard focus, and its current slide, upcoming slide, time, and notes areas not being named regions. Changing layouts no longer forwards control keys to the slides, and the slide navigation buttons no longer introduce an unrelated complementary landmark. (author: @jooyoungseo)

## Formats

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
diff --git a/js/controllers/controls.js b/js/controllers/controls.js
index 3d150da..5913bbf 100644
--- a/js/controllers/controls.js
+++ b/js/controllers/controls.js
@@ -35,7 +35,7 @@ export default class Controls {
const rtl = this.Reveal.getConfig().rtl;
const revealElement = this.Reveal.getRevealElement();

- this.element = document.createElement( 'aside' );
+ this.element = document.createElement( 'div' );
this.element.className = 'controls';
this.element.innerHTML =
`<button class="navigate-left" aria-label="${ rtl ? 'next slide' : 'previous slide' }"><div class="controls-arrow"></div></button>
@@ -276,4 +276,4 @@ export default class Controls {

}

-}
\ No newline at end of file
+}
diff --git a/plugin/notes/speaker-view.html b/plugin/notes/speaker-view.html
index 55bc01f..5653edc 100644
--- a/plugin/notes/speaker-view.html
+++ b/plugin/notes/speaker-view.html
@@ -206,6 +206,11 @@
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}

+ #speaker-layout:focus-within {
+ outline: 2px solid currentColor;
+ outline-offset: 3px;
+ }
+
#speaker-layout select:focus {
outline: none;
box-shadow: none;
@@ -308,10 +313,10 @@

<div id="connection-status">Loading speaker view...</div>

- <div id="current-slide"></div>
- <div id="upcoming-slide"><span class="overlay-element label">Upcoming</span></div>
+ <div id="current-slide" role="region" aria-label="Current slide"></div>
+ <div id="upcoming-slide" role="region" aria-label="Upcoming slide"><span class="overlay-element label">Upcoming</span></div>
<div id="speaker-controls">
- <div class="speaker-controls-time">
+ <div class="speaker-controls-time" role="region" aria-label="Time">
<h4 class="label">Time <span class="reset-button">Click to Reset</span></h4>
<div class="clock">
<span class="clock-value">0:00 AM</span>
@@ -327,14 +332,14 @@
</div>
</div>

- <div class="speaker-controls-notes hidden">
+ <div class="speaker-controls-notes hidden" role="region" aria-label="Speaker notes">
<h4 class="label">Notes</h4>
<div class="value"></div>
</div>
</div>
<div id="speaker-layout" class="overlay-element interactive">
- <span class="speaker-layout-label"></span>
- <select class="speaker-layout-dropdown"></select>
+ <label for="speaker-layout-dropdown" class="speaker-layout-label">Layout</label><span class="speaker-layout-value" aria-hidden="true"></span>
+ <select id="speaker-layout-dropdown" class="speaker-layout-dropdown"></select>
</div>

<script>
@@ -519,6 +524,9 @@
event.preventDefault();
return false;
}
+ // Let native controls handle their own keys without advancing slides.
+ if( event.target.closest( 'input, select, textarea, button, [contenteditable]' ) ) return;
+
currentSlide.contentWindow.postMessage( JSON.stringify({ method: 'triggerKey', args: [ event.keyCode ] }), '*' );
} );

@@ -544,12 +552,14 @@
var upcomingURL = data.url + urlSeparator + params + '&scrollActivationWidth=false&controls=false' + hash;

currentSlide = document.createElement( 'iframe' );
+ currentSlide.setAttribute( 'title', 'Current slide preview' );
currentSlide.setAttribute( 'width', 1280 );
currentSlide.setAttribute( 'height', 1024 );
currentSlide.setAttribute( 'src', currentURL );
document.querySelector( '#current-slide' ).appendChild( currentSlide );

upcomingSlide = document.createElement( 'iframe' );
+ upcomingSlide.setAttribute( 'title', 'Upcoming slide preview' );
upcomingSlide.setAttribute( 'width', 640 );
upcomingSlide.setAttribute( 'height', 512 );
upcomingSlide.setAttribute( 'src', upcomingURL );
@@ -778,7 +788,7 @@
function setupLayout() {

layoutDropdown = document.querySelector( '.speaker-layout-dropdown' );
- layoutLabel = document.querySelector( '.speaker-layout-label' );
+ layoutLabel = document.querySelector( '.speaker-layout-value' );

// Render the list of available layouts
for( var id in SPEAKER_LAYOUTS ) {
@@ -808,7 +818,7 @@

var title = SPEAKER_LAYOUTS[ value ];

- layoutLabel.innerHTML = 'Layout' + ( title ? ( ': ' + title ) : '' );
+ layoutLabel.textContent = title ? ( ': ' + title ) : '';
layoutDropdown.value = value;

document.body.setAttribute( 'data-speaker-layout', value );
@@ -895,4 +905,4 @@

</script>
</body>
-</html>
\ No newline at end of file
+</html>
12 changes: 8 additions & 4 deletions package/src/common/update-html-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,14 +308,18 @@ export async function updateHtmlDependencies(config: Configuration) {
// Backport the unmerged PRs hakimel/reveal.js#3946 and #3949, plus
// native image alt text from reveal.js 6, to 5.1.0, and keep the focus
// where it was when the page is shown again (#15079), which no reveal.js
// release does yet. Before removing these source patches and rebuild
// step, verify all fixes are in the release we bundle. Each patch
// applies after the one before it.
// release does yet. Also backport the speaker-view accessible label,
// named regions, and neutral slide-controls container (#15159, #15160;
// upstream hakimel/reveal.js#3953).
// Before removing these source patches and rebuild step, verify all
// fixes are in the release we bundle. Each patch applies after the
// one before it.
const sourceDir = join(dir, `reveal.js-${version}`);
for (const patch of [
"revealjs-0001-screen-reader-announcements.patch",
"revealjs-0002-image-announcements.patch",
"revealjs-0003-focus-on-return.patch",
"revealjs-0004-speaker-view-accessibility.patch",
]) {
await runCmd("git", [
"-C", sourceDir, "apply", "--ignore-space-change",
Expand All @@ -328,7 +332,7 @@ export async function updateHtmlDependencies(config: Configuration) {
]);
await runCmd("node", [
join(sourceDir, "node_modules", "gulp", "bin", "gulp.js"),
"--cwd", sourceDir, "js",
"--cwd", sourceDir, "js", "plugins",
]);

// Copy the desired resource files
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.

Large diffs are not rendered by default.

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,11 @@
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
}

#speaker-layout:focus-within {
outline: 2px solid currentColor;
outline-offset: 3px;
}

#speaker-layout select:focus {
outline: none;
box-shadow: none;
Expand Down Expand Up @@ -308,10 +313,10 @@

<div id="connection-status">Loading speaker view...</div>

<div id="current-slide"></div>
<div id="upcoming-slide"><span class="overlay-element label">Upcoming</span></div>
<div id="current-slide" role="region" aria-label="Current slide"></div>
<div id="upcoming-slide" role="region" aria-label="Upcoming slide"><span class="overlay-element label">Upcoming</span></div>
<div id="speaker-controls">
<div class="speaker-controls-time">
<div class="speaker-controls-time" role="region" aria-label="Time">
<h4 class="label">Time <span class="reset-button">Click to Reset</span></h4>
<div class="clock">
<span class="clock-value">0:00 AM</span>
Expand All @@ -327,14 +332,14 @@ <h4 class="label pacing-title" style="display: none">Pacing – Time to finish c
</div>
</div>

<div class="speaker-controls-notes hidden">
<div class="speaker-controls-notes hidden" role="region" aria-label="Speaker notes">
<h4 class="label">Notes</h4>
<div class="value"></div>
</div>
</div>
<div id="speaker-layout" class="overlay-element interactive">
<span class="speaker-layout-label"></span>
<select class="speaker-layout-dropdown"></select>
<label for="speaker-layout-dropdown" class="speaker-layout-label">Layout</label><span class="speaker-layout-value" aria-hidden="true"></span>
<select id="speaker-layout-dropdown" class="speaker-layout-dropdown"></select>
</div>

<script>
Expand Down Expand Up @@ -519,6 +524,9 @@ <h4 class="label">Notes</h4>
event.preventDefault();
return false;
}
// Let native controls handle their own keys without advancing slides.
if( event.target.closest( 'input, select, textarea, button, [contenteditable]' ) ) return;

currentSlide.contentWindow.postMessage( JSON.stringify({ method: 'triggerKey', args: [ event.keyCode ] }), '*' );
} );

Expand All @@ -544,12 +552,14 @@ <h4 class="label">Notes</h4>
var upcomingURL = data.url + urlSeparator + params + '&scrollActivationWidth=false&controls=false' + hash;

currentSlide = document.createElement( 'iframe' );
currentSlide.setAttribute( 'title', 'Current slide preview' );
currentSlide.setAttribute( 'width', 1280 );
currentSlide.setAttribute( 'height', 1024 );
currentSlide.setAttribute( 'src', currentURL );
document.querySelector( '#current-slide' ).appendChild( currentSlide );

upcomingSlide = document.createElement( 'iframe' );
upcomingSlide.setAttribute( 'title', 'Upcoming slide preview' );
upcomingSlide.setAttribute( 'width', 640 );
upcomingSlide.setAttribute( 'height', 512 );
upcomingSlide.setAttribute( 'src', upcomingURL );
Expand Down Expand Up @@ -778,7 +788,7 @@ <h4 class="label">Notes</h4>
function setupLayout() {

layoutDropdown = document.querySelector( '.speaker-layout-dropdown' );
layoutLabel = document.querySelector( '.speaker-layout-label' );
layoutLabel = document.querySelector( '.speaker-layout-value' );

// Render the list of available layouts
for( var id in SPEAKER_LAYOUTS ) {
Expand Down Expand Up @@ -808,7 +818,7 @@ <h4 class="label">Notes</h4>

var title = SPEAKER_LAYOUTS[ value ];

layoutLabel.innerHTML = 'Layout' + ( title ? ( ': ' + title ) : '' );
layoutLabel.textContent = title ? ( ': ' + title ) : '';
layoutDropdown.value = value;

document.body.setAttribute( 'data-speaker-layout', value );
Expand Down Expand Up @@ -895,4 +905,4 @@ <h4 class="label">Notes</h4>

</script>
</body>
</html>
</html>
30 changes: 30 additions & 0 deletions tests/docs/playwright/revealjs/speaker-view-a11y.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
format:
revealjs:
transition: none
controls: true
---

## First slide

First slide content.

::: {.fragment}
A fragment.
:::

::: {.notes}
First slide notes.
:::

## Slide without notes

This slide deliberately has no notes.

## Last slide

Last slide content.

::: {.notes}
Last slide notes.
:::
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { expect, Page, test } from '@playwright/test';

const openSpeaker = async (page: Page) => {
await page.goto('./revealjs/speaker-view-a11y.html#/first-slide');
await expect(page.locator('.reveal.ready')).toBeAttached();
const popup = page.waitForEvent('popup');
await page.keyboard.press('s');
const speaker = await popup;
await expect(speaker.locator('.speaker-controls-notes .value')).toHaveText('First slide notes.');
return speaker;
};

const panels = ['Current slide', 'Upcoming slide', 'Time', 'Speaker notes'];

test.describe('revealjs speaker view accessibility (#15159, #15160)', () => {
test('Layout has a stable accessible name, a selected value, and visible focus', async ({ page }) => {
const speaker = await openSpeaker(page);
const select = speaker.getByRole('combobox', { name: 'Layout', exact: true });
await expect(select).toHaveAccessibleName('Layout');
await expect(select).toHaveValue('default');
const indices = await page.evaluate(() => (window as any).Reveal.getIndices());
await select.focus();
await expect(speaker.locator('#speaker-layout')).toHaveCSS('outline-style', 'solid');
await select.press('ArrowDown');
await expect(select).toHaveValue('wide');
await expect(speaker.locator('.speaker-layout-value')).toHaveText(': Wide');
await expect(select).toHaveAccessibleName('Layout');
// Speaker-to-deck state forwarding is debounced by 500 ms. Check after
// that window so a select arrow key cannot silently advance the deck. The
// first slide has a fragment, so compare the fragment index too.
await page.waitForTimeout(750);
expect(await page.evaluate(() => (window as any).Reveal.getIndices())).toEqual(indices);
});

for (const layout of ['default', 'wide', 'tall', 'notes-only']) {
test(`named regions follow the ${layout} layout`, async ({ page }) => {
const speaker = await openSpeaker(page);
await speaker.getByRole('combobox', { name: 'Layout', exact: true }).selectOption(layout);
await expect(speaker.locator('body')).toHaveAttribute('data-speaker-layout', layout);
for (const name of panels) {
const visible = layout !== 'notes-only' || name === 'Time' || name === 'Speaker notes';
await expect(speaker.getByRole('region', { name, exact: true })).toHaveCount(visible ? 1 : 0);
}
});
}

test('preview frames are named and controls retain buttons without a landmark', async ({ page }) => {
const speaker = await openSpeaker(page);
await expect(speaker.locator('#current-slide iframe')).toHaveAttribute('title', 'Current slide preview');
await expect(speaker.locator('#upcoming-slide iframe')).toHaveAttribute('title', 'Upcoming slide preview');
const preview = speaker.frameLocator('#current-slide iframe');
for (const scope of [page, preview]) {
await expect(scope.locator('.controls')).toHaveJSProperty('tagName', 'DIV');
await expect(scope.locator('.controls button')).toHaveCount(4);
await expect(scope.getByRole('complementary')).toHaveCount(0);
await expect(scope.getByRole('button', { name: 'next slide', exact: true })).toBeEnabled();
}
// The controls still work in the main deck and the speaker preview.
await page.getByRole('button', { name: 'next slide', exact: true }).press('Enter');
// The first activation reveals the fragment; the second moves to slide 2.
await page.getByRole('button', { name: 'next slide', exact: true }).press('Enter');
await expect(page.locator('section.present')).toHaveId('slide-without-notes');
await expect(preview.locator('section.present')).toHaveId('slide-without-notes');
await expect(speaker.locator('.speaker-controls-notes')).not.toBeVisible();
await preview.getByRole('button', { name: 'previous slide', exact: true }).click();
await expect(page.locator('section.present')).toHaveId('first-slide');
});

test('notes stay available across fragments, disappear when empty, and return', async ({ page }) => {
const speaker = await openSpeaker(page);
await page.evaluate(() => (window as any).Reveal.nextFragment());
await expect(speaker.getByRole('region', { name: 'Speaker notes', exact: true })).toContainText('First slide notes.');
await page.evaluate(() => (window as any).Reveal.slide(1));
await expect(speaker.getByRole('region', { name: 'Speaker notes', exact: true })).toHaveCount(0);
await page.evaluate(() => (window as any).Reveal.slide(2));
await expect(speaker.getByRole('region', { name: 'Speaker notes', exact: true })).toContainText('Last slide notes.');
});
});
Loading