From 7c26e0a1a081bb96381f63489e6667b2999d63be Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 7 Oct 2026 17:08:07 +0200 Subject: [PATCH 1/3] Restore tabby keyboard navigation for HTML tabsets without Bootstrap (#730) HTML documents without Bootstrap (theme: none) use Quarto's copy of tabby (upstream cferdinandi/tabby v12.0.3). A 2021 patch made revealjs tabsets keyboard-usable by dropping the roving tabindex, activating tabs on focus, and limiting key handling to Home/End, so arrow keys stay with slide navigation. Only the positive tabindex numbering was scoped to revealjs; the rest applied to every tabby user. In HTML, Tab from the selected tab then lands on the next tab, selects it, and the panel content is unreachable. Outside revealjs, tabby now behaves as upstream again: only the selected tab is in the Tab order, arrow keys and Home/End switch tabs, and focus alone does not select a tab. revealjs keeps its current behavior. Handled keys now also call preventDefault: Firefox and WebKit otherwise scroll the page on Home/End (and arrow keys) while switching tabs. Bootstrap tabs do the same. --- news/changelog-1.11.md | 1 + src/resources/formats/html/tabby/js/tabby.js | 34 ++++++++++++-- .../html/tabsets/tabby-keyboard.qmd | 29 ++++++++++++ .../playwright/tests/html-tabsets.spec.ts | 47 +++++++++++++++++++ 4 files changed, 107 insertions(+), 4 deletions(-) create mode 100644 tests/docs/playwright/html/tabsets/tabby-keyboard.qmd diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index e9da3f5f958..7349663a049 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -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 ` + +::: {.panel-tabset} + +### Tab A + + + +### Tab B + + + +### Tab C + + + +::: + +::: {style="height: 300vh"} +::: diff --git a/tests/integration/playwright/tests/html-tabsets.spec.ts b/tests/integration/playwright/tests/html-tabsets.spec.ts index 38f5e78a7c2..35dc0479c71 100644 --- a/tests/integration/playwright/tests/html-tabsets.spec.ts +++ b/tests/integration/playwright/tests/html-tabsets.spec.ts @@ -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'); + }); }); \ No newline at end of file From 08c7724cd029fa2bec62366e3793f2790892be15 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 8 Oct 2026 13:12:07 +0200 Subject: [PATCH 2/3] Keep Home/End on a revealjs tabset tab from changing slide In revealjs, tabby selects the first or last tab on Home/End, but the keydown event kept propagating to reveal's own keyboard handler, which treats Home/End as "go to first/last slide". Selecting a tab from the keyboard therefore also navigated away from the slide holding the tabset. tabby listens on the tablist in the capture phase and reveal listens on the document, so stopping propagation once tabby has handled the key leaves reveal unaware of it. Outside revealjs, this also keeps the arrow keys and Home/End that switch tabs from reaching any other page-level keyboard handler, as with Bootstrap tabs. --- src/resources/formats/html/tabby/js/tabby.js | 4 ++- .../revealjs/tabset-focus-order.qmd | 30 +++++++++++++++++++ .../playwright/tests/revealjs.spec.ts | 15 ++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) create mode 100644 tests/docs/playwright/revealjs/tabset-focus-order.qmd diff --git a/src/resources/formats/html/tabby/js/tabby.js b/src/resources/formats/html/tabby/js/tabby.js index 7eabd8f9d3d..94a2bb9493c 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -405,8 +405,10 @@ ]; if (keys.indexOf(event.key) < 0) return; - // Keep the key from also scrolling the page + // Keep the key from also scrolling the page or, in revealjs, from + // changing slide event.preventDefault(); + event.stopPropagation(); // Switch tabs switchTabs(tab, event.key); diff --git a/tests/docs/playwright/revealjs/tabset-focus-order.qmd b/tests/docs/playwright/revealjs/tabset-focus-order.qmd new file mode 100644 index 00000000000..b1bd68db25b --- /dev/null +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -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. diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index c15f05cfbcf..9b23742fef9 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -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'); +}); From a207af32cf13dd4a03b6ac268d17fdae0bc91608 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 8 Oct 2026 13:44:07 +0200 Subject: [PATCH 3/3] Add changelog entry for revealjs tabset Home/End fix --- news/changelog-1.11.md | 1 + 1 file changed, 1 insertion(+) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 7349663a049..35895e97573 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -46,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`