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
2 changes: 2 additions & 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

- ([#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`).
- ([#12116](https://github.com/quarto-dev/quarto-cli/issues/12116), [#4935](https://github.com/quarto-dev/quarto-cli/issues/4935)): Website sidebar section toggles are now `<button>` elements, so they are keyboard accessible and properly announced.
Expand Down Expand Up @@ -45,6 +46,7 @@ All changes included in 1.11:
### `revealjs`

- ([#14996](https://github.com/quarto-dev/quarto-cli/pull/14996)): Fix `controls: auto` and `previewLinks: auto` set at the top level of a revealjs document's front matter producing a presentation that fails to load, because `auto` was written unquoted into the generated JavaScript.
- ([#15007](https://github.com/quarto-dev/quarto-cli/pull/15007)): Fix Home/End on a focused tabset tab also jumping to the first or last slide in addition to switching tabs.

### `typst`

Expand Down
36 changes: 32 additions & 4 deletions src/resources/formats/html/tabby/js/tabby.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@
tab.dispatchEvent(event);
};

// In revealjs, arrow keys belong to slide navigation: tabs stay in the Tab
// order and activate on focus. Elsewhere tabs use a roving tabindex with
// arrow key navigation.
var isReveal = function () {
return window.document.body.classList.contains("reveal-viewport");
};

var focusHandler = function (event) {
toggle(event.target);
};
Expand Down Expand Up @@ -134,11 +141,12 @@
tab.setAttribute("aria-selected", "true");
} else {
tab.setAttribute("aria-selected", "false");
if (!isReveal()) tab.setAttribute("tabindex", "-1");
content.setAttribute("hidden", "hidden");
}

// add focus event listender
tab.addEventListener("focus", focusHandler);
if (isReveal()) tab.addEventListener("focus", focusHandler);
};

/**
Expand All @@ -155,6 +163,7 @@

// Hide the tab
tab.setAttribute("aria-selected", "false");
if (!isReveal()) tab.setAttribute("tabindex", "-1");

// Hide the content
if (!content) return { previousTab: tab };
Expand All @@ -174,6 +183,7 @@
*/
var show = function (tab, content) {
tab.setAttribute("aria-selected", "true");
if (!isReveal()) tab.setAttribute("tabindex", "0");
content.removeAttribute("hidden");
tab.focus();
};
Expand Down Expand Up @@ -313,8 +323,7 @@
tabWrapper.setAttribute("role", "tablist");

// Add roles to tabs. provide dynanmic tab indexes if we are within reveal
var contentTabindexes =
window.document.body.classList.contains("reveal-viewport");
var contentTabindexes = isReveal();
var nextTabindex = 1;
Array.prototype.forEach.call(tabs, function (tab) {
if (contentTabindexes) {
Expand Down Expand Up @@ -380,7 +389,26 @@
if (!tab.matches(selector + ' [role="tab"]')) return;

// Only run for specific keys
if (["Home", "End"].indexOf(event.key) < 0) return;
var keys = isReveal()
? ["Home", "End"]
: [
"ArrowUp",
"ArrowDown",
"ArrowLeft",
"ArrowRight",
"Up",
"Down",
"Left",
"Right",
"Home",
"End",
];
if (keys.indexOf(event.key) < 0) return;

// Keep the key from also scrolling the page or, in revealjs, from
// changing slide
event.preventDefault();
event.stopPropagation();

// Switch tabs
switchTabs(tab, event.key);
Expand Down
29 changes: 29 additions & 0 deletions tests/docs/playwright/html/tabsets/tabby-keyboard.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
pagetitle: Tabby tabset keyboard navigation
format:
html:
theme: none
---

<!-- Buttons rather than links: WebKit skips links when tabbing by default. -->

<button type="button">Button before the tabset</button>

::: {.panel-tabset}

### Tab A

<button type="button">Button in tab A</button>

### Tab B

<button type="button">Button in tab B</button>

### Tab C

<button type="button">Button in tab C</button>

:::

::: {style="height: 300vh"}
:::
30 changes: 30 additions & 0 deletions tests/docs/playwright/revealjs/tabset-focus-order.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
pagetitle: Tabset focus order
format: revealjs
---

## Slide 1

A [link on slide 1](https://quarto.org).

## Slide 2

Plain slide.

## Slide 3

::: {.panel-tabset}

### Tab A

Content A with a [link in tab A](https://quarto.org).

### Tab B

Content B with a [link in tab B](https://quarto.org).

:::

## Slide 4

Plain slide after the tabset.
47 changes: 47 additions & 0 deletions tests/integration/playwright/tests/html-tabsets.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,51 @@ test('Jupyter - Creates working tabsets from for loops', async ({ page }) => {
const tab2Content = tabContent.locator('div.tab-pane').nth(1);
await expect(tab2Content).toHaveClass(/active/);
await expect(tab2Content.locator('img')).toBeVisible();
});

test.describe('Tabby tabsets (theme: none) keyboard navigation', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/html/tabsets/tabby-keyboard.html');
await expect(page.getByRole('tablist')).toBeAttached();
});

test('Tab moves from the selected tab into its panel (#730)', async ({ page }) => {
await page.getByRole('button', { name: 'Button before the tabset' }).focus();
await page.keyboard.press('Tab');
await expect(page.getByRole('tab', { name: 'Tab A' })).toBeFocused();
await page.keyboard.press('Tab');
await expect(page.getByRole('button', { name: 'Button in tab A' })).toBeFocused();
await expect(page.getByRole('tab', { name: 'Tab A' })).toHaveAttribute('aria-selected', 'true');
});

test('Shift+Tab from the panel returns to the selected tab', async ({ page }) => {
await page.getByRole('tab', { name: 'Tab B' }).click();
await page.getByRole('button', { name: 'Button in tab B' }).focus();
await page.keyboard.press('Shift+Tab');
await expect(page.getByRole('tab', { name: 'Tab B' })).toBeFocused();
});

test('Arrow keys move to and select the adjacent tab, wrapping around', async ({ page }) => {
await page.getByRole('tab', { name: 'Tab A' }).focus();
await page.keyboard.press('ArrowRight');
const tabB = page.getByRole('tab', { name: 'Tab B' });
await expect(tabB).toBeFocused();
await expect(tabB).toHaveAttribute('aria-selected', 'true');
await expect(page.getByRole('button', { name: 'Button in tab B' })).toBeVisible();
await page.keyboard.press('ArrowLeft');
await page.keyboard.press('ArrowLeft');
const tabC = page.getByRole('tab', { name: 'Tab C' });
await expect(tabC).toBeFocused();
await expect(tabC).toHaveAttribute('aria-selected', 'true');
});

test('Home and End select the first and last tab without scrolling the page', async ({ page }) => {
await page.getByRole('tab', { name: 'Tab A' }).focus();
const scrollBefore = await page.evaluate(() => window.scrollY);
await page.keyboard.press('End');
await expect(page.getByRole('tab', { name: 'Tab C' })).toHaveAttribute('aria-selected', 'true');
expect(await page.evaluate(() => window.scrollY)).toBe(scrollBefore);
await page.keyboard.press('Home');
await expect(page.getByRole('tab', { name: 'Tab A' })).toHaveAttribute('aria-selected', 'true');
});
});
15 changes: 15 additions & 0 deletions tests/integration/playwright/tests/revealjs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,18 @@ test('internal id for links between slides are working', async ({ page }) => {
await page.getByRole('link', { name: 'Theorem' }).click();
await page.waitForURL(/theorem$/);
});

test('Home and End on a focused tabset tab do not change slide', async ({ page }) => {
await page.goto('./revealjs/tabset-focus-order.html#/slide-3');
// Located by attribute rather than role: if reveal leaves the slide, it marks
// the slide aria-hidden and a role query would fail before the slide check.
const tab = (name: string) => page.locator('[role="tab"]', { hasText: name });
await expect(tab('Tab A')).toBeAttached();
await tab('Tab A').focus();
await page.keyboard.press('End');
await expect(tab('Tab B')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('section.slide.present')).toHaveId('slide-3');
await page.keyboard.press('Home');
await expect(tab('Tab A')).toHaveAttribute('aria-selected', 'true');
await expect(page.locator('section.slide.present')).toHaveId('slide-3');
});
Loading