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 @@ -20,6 +20,7 @@ All changes included in 1.11:
- ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Translate the new `navigation-*-label` keys in all 33 built-in language files. The values are drawn from existing human-translated interface strings (LibreOffice, GNOME, Wikidata) and each one carries a comment naming its source; values that were adapted rather than used verbatim are marked `needs review`.
- ([#14376](https://github.com/quarto-dev/quarto-cli/issues/14376)): Label the table of contents `<nav>` with its localized title (`aria-labelledby`), in `html` and `revealjs` output, so assistive technology can tell it apart from other navigation landmarks.
- ([#14378](https://github.com/quarto-dev/quarto-cli/issues/14378)): Make scrollable code blocks and cell output keyboard-focusable in HTML output, so keyboard users can Tab to them and scroll with the arrow keys (axe rule `scrollable-region-focusable`, WCAG 2.1.1). While a region overflows it gets `tabindex="0"`, `role="group"`, and a localized `aria-label` (new `scrollable-code-label` and `scrollable-output-label` language keys, translated in all 34 built-in language files); when it fits again the attributes are removed. Chrome and Firefox already focus such regions natively; this adds Safari coverage and an accessible name.
- ([#14795](https://github.com/quarto-dev/quarto-cli/issues/14795)): Fix keyboard focus in `revealjs` presentations reaching links and controls on nearby slides that are not shown. Tab now stays on the current slide, except in overview, scroll and print views where all slides are shown.
- ([#14952](https://github.com/quarto-dev/quarto-cli/issues/14952)): Fix `revealjs` presentations disabling pinch-zoom on touch devices. The viewport `<meta>` tag no longer sets `maximum-scale=1.0` and `user-scalable=no`, which axe-core flagged (`meta-viewport`).

## Formats
Expand Down
12 changes: 10 additions & 2 deletions src/resources/formats/html/axe/axe-check.js
Original file line number Diff line number Diff line change
Expand Up @@ -472,8 +472,8 @@ class QuartoAxeChecker {
this.scanGeneration = 0;
}
// In RevealJS, only the current slide is accessible to axe-core because
// non-visible slides have hidden and aria-hidden attributes. Temporarily
// remove these so axe can check all slides, then restore them.
// non-visible slides have hidden, aria-hidden and inert attributes.
// Temporarily remove these so axe can check all slides, then restore them.
revealUnhideSlides() {
const slides = document.querySelectorAll(".reveal .slides section");
if (slides.length === 0) return null;
Expand All @@ -486,6 +486,7 @@ class QuartoAxeChecker {
});
s.removeAttribute("hidden");
s.removeAttribute("aria-hidden");
s.removeAttribute("inert");
});
return saved;
}
Expand All @@ -496,6 +497,13 @@ class QuartoAxeChecker {
if (hidden) el.setAttribute("hidden", "");
if (ariaHidden !== null) el.setAttribute("aria-hidden", ariaHidden);
});
// Recompute inert from live state, mirroring reveal.js's own rule (6.0.2+),
// so a slide change during the scan cannot leave the current slide inert.
const allowAll = Reveal.isOverview() || Reveal.isScrollView() || Reveal.isPrintView();
const current = Reveal.getCurrentSlide();
saved.forEach(({ el }) => {
el.toggleAttribute("inert", !allowAll && el !== current && !el.contains(current));
});
}

async runAxeScan() {
Expand Down
32 changes: 32 additions & 0 deletions src/resources/formats/revealjs/plugins/support/support.js
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,37 @@ window.QuartoSupport = function () {
}
}

// Prevent offscreen slides from receiving keyboard focus.
// Mirrors reveal.js 6.0.2 (hakimel/reveal.js#1587); remove this block
// when the bundled reveal.js is >= 6.0.2.
function updateInertSlides(deck) {
const allowAllSlides = deck.isOverview() || deck.isScrollView() || deck.isPrintView();
const currentSlide = deck.getCurrentSlide();
deck.getRevealElement().querySelectorAll('.slides section').forEach((slide) => {
slide.toggleAttribute(
'inert',
!allowAllSlides && slide !== currentSlide && !slide.contains(currentSlide)
);
});
}

function installInertSlides(deck) {
if (isPrintView()) return;
const update = () => updateInertSlides(deck);
deck.on('ready', update);
// Capture phase so the new slide is focusable before other plugins'
// slidechanged handlers run, since this plugin is registered after them.
deck.on('slidechanged', update, true);
deck.on('overviewshown', update);
deck.on('overviewhidden', update);
// Leaving scroll view (`reveal-scroll` removed from the viewport) dispatches
// no event in reveal.js 5.1.0, and the restored slides carry stale `inert`.
new MutationObserver(update).observe(deck.getViewportElement(), {
attributes: true,
attributeFilter: ['class'],
});
}

// FIXME: Possibly remove this wrapper class when upstream trigger is fixed
// https://github.com/hakimel/reveal.js/issues/3688
// Currently, scrollActivationWidth needs to be unset for toggle to work
Expand Down Expand Up @@ -408,6 +439,7 @@ window.QuartoSupport = function () {
workaroundMermaidDistance(deck);
handleWhiteSpaceInColumns(deck);
installScollViewKeyBindings(deck);
installInertSlides(deck);
// should stay last
cleanEmptyAutoGeneratedContent(deck);
},
Expand Down
47 changes: 47 additions & 0 deletions tests/docs/playwright/revealjs/offscreen-slides-focus.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
pagetitle: Off-screen slides focus
format: revealjs
---

## Slide 1 {#slide-1}

<button type="button">Button on slide-1</button>

<script>
document.querySelector('.reveal').addEventListener('slidechanged', (e) => {
const target = e.currentSlide.querySelector('.focus-on-enter');
if (target) target.focus();
});
</script>

## Slide 2 {#slide-2}

<button type="button">Button on slide-2</button>

## Slide 3 {#slide-3}

<button type="button">Button on slide-3</button>

## Slide 4 {#slide-4}

<button type="button">Button on slide-4</button>

## Slide 5 {#slide-5}

<button type="button">Button on slide-5</button>

## Slide 6 {#slide-6}

<button type="button" class="focus-on-enter">Button on slide-6</button>

# Stack {#stack}

<button type="button">Button on stack</button>

## Stack A {#stack-a}

<button type="button">Button on stack-a</button>

## Stack B {#stack-b}

<button type="button">Button on stack-b</button>
99 changes: 99 additions & 0 deletions tests/integration/playwright/tests/revealjs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,102 @@ test('Home and End on a focused tabset tab do not change slide', async ({ page }
await expect(tab('Tab A')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('section.slide.present')).toHaveId('slide-3');
});

test.describe('off-screen slides are not focusable (#14795)', () => {
// Not getByRole: non-present slides carry aria-hidden, which getByRole skips.
const slideButton = (page: Page, id: string) =>
page.locator('button', { hasText: `Button on ${id}` });
const focusOutsideCurrentSlide = (page: Page) =>
page.locator('.reveal .slides section:not(.present) :focus');
const gotoSlide = async (page: Page, hash: string, query = '') => {
await page.goto(`./revealjs/offscreen-slides-focus.html${query}${hash}`);
await expect(page.locator('.reveal.ready')).toBeAttached();
};
const isScrollView = (page: Page) => page.evaluate(() => (window as any).Reveal.isScrollView());
const currentSlideId = (page: Page) =>
page.evaluate(() => (window as any).Reveal.getCurrentSlide().id);

test('Tab on a slide reaches only that slide (#14795)', async ({ page }) => {
await gotoSlide(page, '#/slide-3');
await page.keyboard.press('Tab');
await expect(slideButton(page, 'slide-3')).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.locator('.reveal .slides section :focus')).toHaveCount(0);
});

test('Tab after keyboard navigation stays on the new slide', async ({ page }) => {
await gotoSlide(page, '#/slide-2');
await page.keyboard.press('ArrowRight');
await expect(page).toHaveURL(/#\/slide-3$/);
await page.keyboard.press('Tab');
await expect(slideButton(page, 'slide-3')).toBeFocused();
});

test('Tab in a vertical stack reaches only the current child', async ({ page }) => {
await gotoSlide(page, '#/stack-b');
await page.keyboard.press('Tab');
await expect(slideButton(page, 'stack-b')).toBeFocused();
await page.keyboard.press('Tab');
await expect(focusOutsideCurrentSlide(page)).toHaveCount(0);
});

test("slidechanged handler registered before Quarto's can focus the new slide", async ({ page }) => {
await gotoSlide(page, '#/slide-5');
await page.keyboard.press('ArrowRight');
await expect(slideButton(page, 'slide-6')).toBeFocused();
});

test('overview: slides are clickable, inert returns on exit', async ({ page }) => {
await gotoSlide(page, '#/slide-3');
await page.keyboard.press('o');
await expect(page.locator('.reveal.overview')).toBeAttached();
// Corner click: a centre click focuses the button on mousedown, so the next Tab would skip it.
await page.locator('section#slide-5').click({ position: { x: 5, y: 5 } });
await expect(page).toHaveURL(/#\/slide-5$/);
await expect(page.locator('.reveal.overview')).toHaveCount(0);
await page.keyboard.press('Tab');
await expect(slideButton(page, 'slide-5')).toBeFocused();
});

test('scroll view from config: every slide is focusable', async ({ page }) => {
await gotoSlide(page, '', '?view=scroll');
await expect.poll(() => isScrollView(page)).toBe(true);
await slideButton(page, 'slide-4').focus();
await expect(slideButton(page, 'slide-4')).toBeFocused();
});

test('scroll view toggle with R: focusable inside, current slide reachable after leaving', async ({ page }) => {
await gotoSlide(page, '#/slide-2');
await page.keyboard.press('R');
await expect.poll(() => isScrollView(page)).toBe(true);
await slideButton(page, 'slide-4').focus();
await expect(slideButton(page, 'slide-4')).toBeFocused();
// Explicit v index: in scroll view Reveal.slide(3) is a no-op.
await page.evaluate(() => (window as any).Reveal.slide(3, 0));
await expect.poll(() => currentSlideId(page)).toBe('slide-4');
await page.keyboard.press('R');
await expect.poll(() => isScrollView(page)).toBe(false);
await page.keyboard.press('Tab');
await expect(slideButton(page, 'slide-4')).toBeFocused();
});

test('responsive scroll view below scrollActivationWidth', async ({ page }) => {
await gotoSlide(page, '#/slide-2');
await page.setViewportSize({ width: 400, height: 700 });
await expect.poll(() => isScrollView(page)).toBe(true);
await slideButton(page, 'slide-4').focus();
await expect(slideButton(page, 'slide-4')).toBeFocused();
await page.evaluate(() => (window as any).Reveal.slide(3, 0));
await expect.poll(() => currentSlideId(page)).toBe('slide-4');
await page.setViewportSize({ width: 1280, height: 720 });
await expect.poll(() => isScrollView(page)).toBe(false);
await page.keyboard.press('Tab');
await expect(slideButton(page, 'slide-4')).toBeFocused();
});

test('print view: no slide is inert', async ({ page }) => {
await page.goto('./revealjs/offscreen-slides-focus.html?print-pdf');
await expect(page.locator('.pdf-page').first()).toBeAttached();
await expect(page.locator('.reveal .slides section[inert]')).toHaveCount(0);
});
});
Loading