From 40d8f0f1cb865611ecce817e8fd6e5636b119892 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 7 Oct 2026 15:58:27 +0200 Subject: [PATCH 01/10] WIP: tests for revealjs tabset keyboard focus order (#14970) Tabby assigns positive tabindex values to tabs and panel content in revealjs, so they come before content of the current slide in the Tab order. The first test captures that (fails on main). The second pins the tab, panel content, next tab order that the positive values were introduced for, so a fix that just uses tabindex="0" does not lose it. Fix approach pending discussion on the issue. --- .../playwright/tests/revealjs.spec.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index 8381535e91..c3ae93668a 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -317,3 +317,33 @@ test.describe('the slide menu button has an accessible name', () => { await expect(button(page)).toHaveAccessibleName('Menu des diapositives'); }); }); + +test.describe('tabset keyboard focus order', () => { + // Tabs live on non-present slides, which reveal marks aria-hidden, so they + // are located by attribute and text rather than by accessible role. + const tab = (page: Page, name: string) => + page.locator('[role="tab"]', { hasText: name }); + + const gotoSlide = async (page: Page, hash: string) => { + await page.goto(`./revealjs/tabset-focus-order.html${hash}`); + await expect(page.locator('.reveal.ready')).toBeAttached(); + await expect(tab(page, 'Tab A')).toBeAttached(); + }; + + test('first Tab stays on the current slide (#14970)', async ({ page }) => { + await gotoSlide(page, '#/slide-1'); + await page.keyboard.press('Tab'); + await expect(page.getByRole('link', { name: 'link on slide 1' })).toBeFocused(); + }); + + test('Tab moves from a tab into its panel content', async ({ page }) => { + await gotoSlide(page, '#/slide-3'); + await tab(page, 'Tab A').focus(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('link', { name: 'link in tab A' })).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(tab(page, 'Tab B')).toBeFocused(); + await page.keyboard.press('Tab'); + await expect(page.getByRole('link', { name: 'link in tab B' })).toBeFocused(); + }); +}); From 13cf4c32aac22a49c6339df73a912b0d6ce6607a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 22 Oct 2026 12:22:04 +0200 Subject: [PATCH 02/10] Fix revealjs tabset keyboard focus order (#14970) In revealjs, tabby gave each tab and each focusable element of its panel the next positive tabindex, so that Tab went tab, panel content, next tab instead of tab to tab (tabs come before all panels in the DOM, and arrow keys are kept for slide navigation). Positive tabindex values come before every other element in the Tab order, so on a slide the tabset was reached before links placed above it, and two tabsets on one slide both counted from 1 and were visited interleaved. Tabs now keep tabindex="0" and the same tab, panel, next tab order comes from a Tab keydown handler on tabs and panels: Tab on a tab goes to the first focusable element of its panel, Tab on the last one goes to the next tab, and Shift+Tab mirrors both, selecting the previous tab first because focus selects tabs in revealjs. Anything else is left to the browser, so focus enters and leaves the tabset in document order. Arrow keys still change slides when a tab has focus. Roving tabindex with arrow keys switching tabs (the pattern used outside revealjs since #15007) was considered and not used: clicking a tab leaves focus on it, so a presenter using arrow keys or a clicker would switch tabs instead of slides. The reporter's literal repro (first Tab on an earlier slide reaching a tab on a later slide) was already fixed by #15010, which makes non-present slides inert. --- news/changelog-1.11.md | 1 + src/resources/formats/html/tabby/js/tabby.js | 75 ++++++++++++++----- .../revealjs/tabset-focus-order.qmd | 44 +++++++++++ .../playwright/tests/revealjs.spec.ts | 73 +++++++++++++++--- 4 files changed, 164 insertions(+), 29 deletions(-) diff --git a/news/changelog-1.11.md b/news/changelog-1.11.md index 9cecf846cd..8820e4e7b4 100644 --- a/news/changelog-1.11.md +++ b/news/changelog-1.11.md @@ -33,6 +33,7 @@ All changes included in 1.11: - ([#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 `` tag no longer sets `maximum-scale=1.0` and `user-scalable=no`, which axe-core flagged (`meta-viewport`). - ([#14968](https://github.com/quarto-dev/quarto-cli/issues/14968)): The header of notebook preview pages (back link, notebook title, download link) is now a navigation landmark named after the notebook, so it is reachable through landmark navigation and no longer flagged by axe-core (`region`). +- ([#14970](https://github.com/quarto-dev/quarto-cli/issues/14970)): Fix keyboard focus order of tabsets in `revealjs` presentations. Tab now follows the order of the slide: tabs and their panel content no longer come before other links on the slide, and two tabsets on the same slide are no longer mixed together. - ([#15011](https://github.com/quarto-dev/quarto-cli/issues/15011)): Fix the slide menu of `revealjs` presentations being read by screen readers while it is closed and off screen, and Tab moving keyboard focus to it once it lists more slides than fit. (author: @jooyoungseo) - ([#15015](https://github.com/quarto-dev/quarto-cli/issues/15015)): Fix the "Back to Article" link of notebook previews in website projects not being reachable or usable with the keyboard. - ([#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. (author: @jooyoungseo) diff --git a/src/resources/formats/html/tabby/js/tabby.js b/src/resources/formats/html/tabby/js/tabby.js index 94a2bb9493..cad0662013 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -87,10 +87,56 @@ 'a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])' ), ].filter( - (el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden") + (el) => + !el.hasAttribute("disabled") && + !el.getAttribute("aria-hidden") && + el.getClientRects().length > 0 ); }; + // In revealjs, Tab goes from a tab to its panel content and from the end + // of that content to the next tab; Shift+Tab goes the other way. Tabs come + // before all panels in the DOM, so the browser alone would go tab to tab. + var tabOrderHandler = function (event) { + if (event.key !== "Tab" || event.defaultPrevented) return; + var current = event.currentTarget; + var onTab = current.getAttribute("role") === "tab"; + var tab = onTab + ? current + : document.getElementById(current.getAttribute("aria-labelledby")); + var map = tab && getTabsMap(tab); + if (!map) return; + + var target; + if (onTab && !event.shiftKey) { + target = getKeyboardFocusableElements( + document.querySelector(tab.hash) + )[0]; + } else if (onTab) { + var previous = map.tabs[map.index - 1]; + if (!previous) return; + toggle(previous); + target = + getKeyboardFocusableElements( + document.querySelector(previous.hash) + ).pop() || previous; + } else { + var focusables = getKeyboardFocusableElements(current); + if (event.shiftKey && event.target === focusables[0]) { + target = tab; + } else if ( + !event.shiftKey && + event.target === focusables[focusables.length - 1] + ) { + target = map.tabs[map.index + 1]; + } + } + if (!target) return; + + event.preventDefault(); + target.focus(); + }; + /** * Remove roles and attributes from a tab and its content * @param {Node} tab The tab @@ -105,6 +151,8 @@ // remove event listener tab.removeEventListener("focus", focusHandler, true); + tab.removeEventListener("keydown", tabOrderHandler); + content.removeEventListener("keydown", tabOrderHandler); // Remove roles tab.removeAttribute("role"); @@ -145,8 +193,12 @@ content.setAttribute("hidden", "hidden"); } - // add focus event listender - if (isReveal()) tab.addEventListener("focus", focusHandler); + // add focus and Tab order event listeners + if (isReveal()) { + tab.addEventListener("focus", focusHandler); + tab.addEventListener("keydown", tabOrderHandler); + content.addEventListener("keydown", tabOrderHandler); + } }; /** @@ -322,27 +374,14 @@ // Add role to wrapper tabWrapper.setAttribute("role", "tablist"); - // Add roles to tabs. provide dynanmic tab indexes if we are within reveal - var contentTabindexes = isReveal(); - var nextTabindex = 1; + // Add roles to tabs Array.prototype.forEach.call(tabs, function (tab) { - if (contentTabindexes) { - tab.setAttribute("tabindex", "" + nextTabindex++); - } else { - tab.setAttribute("tabindex", "0"); - } + tab.setAttribute("tabindex", "0"); // Get the tab content var content = document.querySelector(tab.hash); if (!content) return; - // set tab indexes for content - if (contentTabindexes) { - getKeyboardFocusableElements(content).forEach(function (el) { - el.setAttribute("tabindex", "" + nextTabindex++); - }); - } - // Setup the tab setupTab(tab, content, settings); }); diff --git a/tests/docs/playwright/revealjs/tabset-focus-order.qmd b/tests/docs/playwright/revealjs/tabset-focus-order.qmd index b1bd68db25..9ad06e5069 100644 --- a/tests/docs/playwright/revealjs/tabset-focus-order.qmd +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -28,3 +28,47 @@ Content B with a [link in tab B](https://quarto.org). ## Slide 4 Plain slide after the tabset. + +## Links around a tabset + +A [link before the tabset](https://quarto.org). + +::: {.panel-tabset} + +### Tab C + +Content C with a [link in tab C](https://quarto.org). + +### Tab D + +Content D with a [link in tab D](https://quarto.org). + +::: + +A [link after the tabset](https://quarto.org). + +## Two tabsets + +::: {.panel-tabset} + +### First A + +[link in first A](https://quarto.org) + +### First B + +[link in first B](https://quarto.org) + +::: + +::: {.panel-tabset} + +### Second A + +Nothing to focus in this panel. + +### Second B + +[link in second B](https://quarto.org) + +::: diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index c3ae93668a..6cfb9557a0 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -1,4 +1,4 @@ -import { test, expect, Page } from '@playwright/test'; +import { test, expect, Locator, Page } from '@playwright/test'; import { tabKey } from '../src/utils'; test('logo and footer are correctly shown in default mode', async ({ page }) => { @@ -330,20 +330,71 @@ test.describe('tabset keyboard focus order', () => { await expect(tab(page, 'Tab A')).toBeAttached(); }; - test('first Tab stays on the current slide (#14970)', async ({ page }) => { + const link = (page: Page, name: string) => page.getByRole('link', { name }); + + // Presses Tab (or Shift+Tab) once per target and checks each gets focus in turn. + const expectTabOrder = async ( + page: Page, + browserName: string, + targets: Locator[], + shift = false, + ) => { + for (const target of targets) { + await page.keyboard.press(tabKey(browserName, shift)); + await expect(target).toBeFocused(); + } + }; + + test('first Tab stays on the current slide (#14970)', async ({ page, browserName }) => { await gotoSlide(page, '#/slide-1'); - await page.keyboard.press('Tab'); - await expect(page.getByRole('link', { name: 'link on slide 1' })).toBeFocused(); + await expectTabOrder(page, browserName, [link(page, 'link on slide 1')]); }); - test('Tab moves from a tab into its panel content', async ({ page }) => { + test('Tab moves from a tab into its panel content', async ({ page, browserName }) => { await gotoSlide(page, '#/slide-3'); await tab(page, 'Tab A').focus(); - await page.keyboard.press('Tab'); - await expect(page.getByRole('link', { name: 'link in tab A' })).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(tab(page, 'Tab B')).toBeFocused(); - await page.keyboard.press('Tab'); - await expect(page.getByRole('link', { name: 'link in tab B' })).toBeFocused(); + await expectTabOrder(page, browserName, [ + link(page, 'link in tab A'), + tab(page, 'Tab B'), + link(page, 'link in tab B'), + ]); + }); + + test('Tab follows document order around a tabset (#14970)', async ({ page, browserName }) => { + await gotoSlide(page, '#/links-around-a-tabset'); + await expectTabOrder(page, browserName, [ + link(page, 'link before the tabset'), + tab(page, 'Tab C'), + link(page, 'link in tab C'), + tab(page, 'Tab D'), + link(page, 'link in tab D'), + link(page, 'link after the tabset'), + ]); + }); + + test('Shift+Tab reverses the order around a tabset', async ({ page, browserName }) => { + await gotoSlide(page, '#/links-around-a-tabset'); + await tab(page, 'Tab D').click(); + await link(page, 'link after the tabset').focus(); + await expectTabOrder(page, browserName, [ + link(page, 'link in tab D'), + tab(page, 'Tab D'), + link(page, 'link in tab C'), + tab(page, 'Tab C'), + link(page, 'link before the tabset'), + ], true); + }); + + test('two tabsets on a slide are traversed one after the other (#14970)', async ({ page, browserName }) => { + await gotoSlide(page, '#/two-tabsets'); + await expectTabOrder(page, browserName, [ + tab(page, 'First A'), + link(page, 'link in first A'), + tab(page, 'First B'), + link(page, 'link in first B'), + tab(page, 'Second A'), + tab(page, 'Second B'), + link(page, 'link in second B'), + ]); }); }); From a8d3b3e636b3396bc9652d7ec0d2783bf3534b8c Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 22 Oct 2026 12:41:22 +0200 Subject: [PATCH 03/10] Match the browser's Tab order for revealjs tabset panel content The Tab handler picks the first or last focusable element of a panel from a list that has to agree with what the browser actually focuses, otherwise it cancels Tab and focuses nothing, leaving focus stuck: - `details` itself cannot take focus, its `summary` does. A panel starting with a `details` (such as folded code) trapped focus on the tab, and one ending with it lost the move to the next tab. - Fragments not yet shown are `visibility: hidden` in revealjs, so their links have a layout box but are skipped by the browser. The list now selects `summary` and only keeps elements with a computed `visibility` of `visible`. Also adds tests for a tabset nested in another tabset, in both directions, and drops a `defaultPrevented` check from the handler: when an inner tabset moves focus, the outer panel cannot match the same key press, because inner tabs precede their panels and selecting a tab hides the other panels at once. The nested tests pass without it. --- src/resources/formats/html/tabby/js/tabby.js | 11 +++- .../revealjs/tabset-focus-order.qmd | 64 +++++++++++++++++++ .../playwright/tests/revealjs.spec.ts | 42 ++++++++++++ 3 files changed, 114 insertions(+), 3 deletions(-) diff --git a/src/resources/formats/html/tabby/js/tabby.js b/src/resources/formats/html/tabby/js/tabby.js index cad0662013..a60f9349c7 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -84,13 +84,17 @@ var getKeyboardFocusableElements = function (element) { return [ ...element.querySelectorAll( - 'a[href], button, input, textarea, select, details,[tabindex]:not([tabindex="-1"])' + 'a[href], button, input, textarea, select, summary,[tabindex]:not([tabindex="-1"])' ), ].filter( + // Shown elements only, as the browser skips the others: content of + // unselected panels (also of a nested tabset) is not rendered, and + // revealjs fragments not yet shown have `visibility: hidden` (el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden") && - el.getClientRects().length > 0 + el.getClientRects().length > 0 && + window.getComputedStyle(el).visibility === "visible" ); }; @@ -98,7 +102,7 @@ // of that content to the next tab; Shift+Tab goes the other way. Tabs come // before all panels in the DOM, so the browser alone would go tab to tab. var tabOrderHandler = function (event) { - if (event.key !== "Tab" || event.defaultPrevented) return; + if (event.key !== "Tab") return; var current = event.currentTarget; var onTab = current.getAttribute("role") === "tab"; var tab = onTab @@ -115,6 +119,7 @@ } else if (onTab) { var previous = map.tabs[map.index - 1]; if (!previous) return; + // Select the previous tab first: its panel content is hidden until then toggle(previous); target = getKeyboardFocusableElements( diff --git a/tests/docs/playwright/revealjs/tabset-focus-order.qmd b/tests/docs/playwright/revealjs/tabset-focus-order.qmd index 9ad06e5069..83d2753f07 100644 --- a/tests/docs/playwright/revealjs/tabset-focus-order.qmd +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -72,3 +72,67 @@ Nothing to focus in this panel. [link in second B](https://quarto.org) ::: + +## Nested tabsets + +::: {.panel-tabset} + +### Outer A + +A [link in outer A](https://quarto.org). + +::: {.panel-tabset} + +#### Inner 1 + +[link in inner 1](https://quarto.org) + +#### Inner 2 + +[link in inner 2](https://quarto.org) + +::: + +### Outer B + +[link in outer B](https://quarto.org) + +::: + +## Details and fragments in a tabset + +::: {.panel-tabset} + +### Tab E + +[link in tab E](https://quarto.org) + +
+details in tab E + +Folded text. + +
+ +### Tab F + +
+details in tab F + +Folded text. + +
+ +[link in tab F](https://quarto.org) + +### Tab G + +::: {.fragment} +[link in a fragment](https://quarto.org) +::: + +[link in tab G](https://quarto.org) + +::: + +A [link after the details tabset](https://quarto.org). diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index 6cfb9557a0..25080e1c15 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -397,4 +397,46 @@ test.describe('tabset keyboard focus order', () => { link(page, 'link in second B'), ]); }); + + test('a nested tabset is traversed inside its outer panel', async ({ page, browserName }) => { + await gotoSlide(page, '#/nested-tabsets'); + await expectTabOrder(page, browserName, [ + tab(page, 'Outer A'), + link(page, 'link in outer A'), + tab(page, 'Inner 1'), + link(page, 'link in inner 1'), + tab(page, 'Inner 2'), + link(page, 'link in inner 2'), + tab(page, 'Outer B'), + link(page, 'link in outer B'), + ]); + }); + + test('Shift+Tab reverses the order through a nested tabset', async ({ page, browserName }) => { + await gotoSlide(page, '#/nested-tabsets'); + await tab(page, 'Outer B').click(); + await link(page, 'link in outer B').focus(); + await expectTabOrder(page, browserName, [ + tab(page, 'Outer B'), + link(page, 'link in inner 1'), + tab(page, 'Inner 1'), + link(page, 'link in outer A'), + tab(page, 'Outer A'), + ], true); + }); + + test('Tab reaches details summaries and skips unrevealed fragments in panels', async ({ page, browserName }) => { + await gotoSlide(page, '#/details-and-fragments-in-a-tabset'); + await tab(page, 'Tab E').focus(); + await expectTabOrder(page, browserName, [ + link(page, 'link in tab E'), + page.locator('summary', { hasText: 'details in tab E' }), + tab(page, 'Tab F'), + page.locator('summary', { hasText: 'details in tab F' }), + link(page, 'link in tab F'), + tab(page, 'Tab G'), + link(page, 'link in tab G'), + link(page, 'link after the details tabset'), + ]); + }); }); From 139f259d09df262d31e9b60b5546f0c141a9493d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 22 Oct 2026 13:27:38 +0200 Subject: [PATCH 04/10] Leave Tab to revealjs tabset panel controls that handle it The tabset Tab handler moved focus to the next tab even when a control in the panel had already cancelled Tab for its own use, as code editors do to indent. A textarea at the end of a panel lost focus to the next tab instead of getting the tab character. The handler again skips events whose default is prevented. It had been dropped because nested tabsets do not need it; this is the case it is for. --- src/resources/formats/html/tabby/js/tabby.js | 3 ++- .../revealjs/tabset-focus-order.qmd | 26 +++++++++++++++++++ .../playwright/tests/revealjs.spec.ts | 9 +++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/src/resources/formats/html/tabby/js/tabby.js b/src/resources/formats/html/tabby/js/tabby.js index a60f9349c7..7966e401f7 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -102,7 +102,8 @@ // of that content to the next tab; Shift+Tab goes the other way. Tabs come // before all panels in the DOM, so the browser alone would go tab to tab. var tabOrderHandler = function (event) { - if (event.key !== "Tab") return; + // Leave Tab to controls that handle it themselves, like code editors + if (event.key !== "Tab" || event.defaultPrevented) return; var current = event.currentTarget; var onTab = current.getAttribute("role") === "tab"; var tab = onTab diff --git a/tests/docs/playwright/revealjs/tabset-focus-order.qmd b/tests/docs/playwright/revealjs/tabset-focus-order.qmd index 83d2753f07..4f0d61c4e6 100644 --- a/tests/docs/playwright/revealjs/tabset-focus-order.qmd +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -136,3 +136,29 @@ Folded text. ::: A [link after the details tabset](https://quarto.org). + +## Control handling Tab in a tabset + +::: {.panel-tabset} + +### Tab H + +```{=html} + + +``` + +### Tab I + +[link in tab I](https://quarto.org) + +::: diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index 25080e1c15..3f5b174503 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -439,4 +439,13 @@ test.describe('tabset keyboard focus order', () => { link(page, 'link after the details tabset'), ]); }); + + test('Tab is left to a panel control that handles it', async ({ page, browserName }) => { + await gotoSlide(page, '#/control-handling-tab-in-a-tabset'); + const editor = page.getByRole('textbox', { name: 'editor in tab H' }); + await editor.focus(); + await page.keyboard.press(tabKey(browserName)); + await expect(editor).toBeFocused(); + await expect(editor).toHaveValue('\t'); + }); }); From f3c0a4a4c5b72592a1a74a400e6786a9ffac20f8 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 22 Oct 2026 13:34:15 +0200 Subject: [PATCH 05/10] Make Shift+Tab into a revealjs tabset the reverse of Tab Shift+Tab from content after a tabset first reaches the selected panel, the first one by default, so the tabs after it were never visited going backwards: link in tab C, Tab C, then out of the tabset, while Tab goes Tab C, link in tab C, Tab D, link in tab D. When keyboard focus enters the tabset from an element after it and the last tab is not selected, the last tab is now selected and focus moves to the end of its panel, from where the existing Shift+Tab handling goes through the tabs in reverse. Only keyboard focus (`:focus-visible`) triggers this: a click on content of the first panel, with focus after the tabset, would otherwise switch to the last tab and hide what was clicked. The Shift+Tab test no longer selects the last tab before starting, which hid the problem. --- src/resources/formats/html/tabby/js/tabby.js | 65 ++++++++++++++++--- .../playwright/tests/revealjs.spec.ts | 10 ++- 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/resources/formats/html/tabby/js/tabby.js b/src/resources/formats/html/tabby/js/tabby.js index 7966e401f7..cc736ff82b 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -98,6 +98,24 @@ ); }; + // The tab of a tab or of a tab panel + var getTab = function (tabOrPanel) { + return tabOrPanel.getAttribute("role") === "tab" + ? tabOrPanel + : document.getElementById(tabOrPanel.getAttribute("aria-labelledby")); + }; + + // Selects a tab and focuses the last focusable element of its panel, or + // the tab itself if there is none. The panel content is hidden until the + // tab is selected. + var focusEndOf = function (tab) { + toggle(tab); + ( + getKeyboardFocusableElements(document.querySelector(tab.hash)).pop() || + tab + ).focus(); + }; + // In revealjs, Tab goes from a tab to its panel content and from the end // of that content to the next tab; Shift+Tab goes the other way. Tabs come // before all panels in the DOM, so the browser alone would go tab to tab. @@ -106,9 +124,7 @@ if (event.key !== "Tab" || event.defaultPrevented) return; var current = event.currentTarget; var onTab = current.getAttribute("role") === "tab"; - var tab = onTab - ? current - : document.getElementById(current.getAttribute("aria-labelledby")); + var tab = getTab(current); var map = tab && getTabsMap(tab); if (!map) return; @@ -120,12 +136,9 @@ } else if (onTab) { var previous = map.tabs[map.index - 1]; if (!previous) return; - // Select the previous tab first: its panel content is hidden until then - toggle(previous); - target = - getKeyboardFocusableElements( - document.querySelector(previous.hash) - ).pop() || previous; + event.preventDefault(); + focusEndOf(previous); + return; } else { var focusables = getKeyboardFocusableElements(current); if (event.shiftKey && event.target === focusables[0]) { @@ -143,6 +156,36 @@ target.focus(); }; + // In revealjs, Shift+Tab from after a tabset reaches the content of the + // selected panel first, which skips the tabs after it. Start from the end + // of the last panel instead, so Shift+Tab is the reverse of Tab. Only for + // keyboard focus: a click in a panel must keep that panel. + var backwardEntryHandler = function (event) { + var from = event.relatedTarget; + var tab = getTab(event.currentTarget); + var map = tab && getTabsMap(tab); + if (!from || !map || !event.target.matches(":focus-visible")) return; + + var tabList = map.tabs[0].closest('[role="tablist"]'); + var fromInside = Array.prototype.some.call(map.tabs, function (tab) { + var content = document.querySelector(tab.hash); + return tab === from || (content && content.contains(from)); + }); + var fromAfter = + from.compareDocumentPosition(tabList) & + Node.DOCUMENT_POSITION_PRECEDING; + var last = map.tabs[map.tabs.length - 1]; + if ( + fromInside || + !fromAfter || + last.getAttribute("aria-selected") === "true" + ) { + return; + } + + focusEndOf(last); + }; + /** * Remove roles and attributes from a tab and its content * @param {Node} tab The tab @@ -159,6 +202,8 @@ tab.removeEventListener("focus", focusHandler, true); tab.removeEventListener("keydown", tabOrderHandler); content.removeEventListener("keydown", tabOrderHandler); + tab.removeEventListener("focusin", backwardEntryHandler); + content.removeEventListener("focusin", backwardEntryHandler); // Remove roles tab.removeAttribute("role"); @@ -204,6 +249,8 @@ tab.addEventListener("focus", focusHandler); tab.addEventListener("keydown", tabOrderHandler); content.addEventListener("keydown", tabOrderHandler); + tab.addEventListener("focusin", backwardEntryHandler); + content.addEventListener("focusin", backwardEntryHandler); } }; diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index 3f5b174503..7ff4d769e3 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -374,7 +374,6 @@ test.describe('tabset keyboard focus order', () => { test('Shift+Tab reverses the order around a tabset', async ({ page, browserName }) => { await gotoSlide(page, '#/links-around-a-tabset'); - await tab(page, 'Tab D').click(); await link(page, 'link after the tabset').focus(); await expectTabOrder(page, browserName, [ link(page, 'link in tab D'), @@ -440,6 +439,15 @@ test.describe('tabset keyboard focus order', () => { ]); }); + test('clicking into a panel from after the tabset keeps that panel', async ({ page }) => { + await gotoSlide(page, '#/details-and-fragments-in-a-tabset'); + await link(page, 'link after the details tabset').focus(); + const summary = page.locator('summary', { hasText: 'details in tab E' }); + await summary.click(); + await expect(summary).toBeFocused(); + await expect(tab(page, 'Tab E')).toHaveAttribute('aria-selected', 'true'); + }); + test('Tab is left to a panel control that handles it', async ({ page, browserName }) => { await gotoSlide(page, '#/control-handling-tab-in-a-tabset'); const editor = page.getByRole('textbox', { name: 'editor in tab H' }); From 0318da2d0bfe9a2ee23250ae8a115100b0f7a267 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 22 Oct 2026 13:49:37 +0200 Subject: [PATCH 06/10] Detect Shift+Tab itself for backward entry into a revealjs tabset Backward entry from after a tabset was detected with `:focus-visible`, but browsers also match it when a text input or textarea is clicked. A click on such a control in the first panel, with focus after the tabset, switched to the last tab and hid the control. The handler now acts only while Shift+Tab is held down, tracked on the document; focus moves during the keydown. It also returned early when the last tab was already selected. With an empty selected panel, Shift+Tab from after the tabset lands on the last tab, which focus has already selected, so the content of the last panel was skipped. It now returns only when focus is already in the last panel. --- src/resources/formats/html/tabby/js/tabby.js | 31 +++++++++++++++++-- .../revealjs/tabset-focus-order.qmd | 18 +++++++++++ .../playwright/tests/revealjs.spec.ts | 19 ++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/src/resources/formats/html/tabby/js/tabby.js b/src/resources/formats/html/tabby/js/tabby.js index cc736ff82b..43d08a6a91 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -156,15 +156,37 @@ target.focus(); }; + // Whether Shift+Tab is being pressed. Focus moves while the key is down, so + // this tells focus changes made by Shift+Tab apart from clicks. + var shiftTabDown = false; + var trackShiftTab = function () { + if (trackShiftTab.done) return; + trackShiftTab.done = true; + document.addEventListener( + "keydown", + function (event) { + shiftTabDown = event.key === "Tab" && event.shiftKey; + }, + true + ); + document.addEventListener( + "keyup", + function () { + shiftTabDown = false; + }, + true + ); + }; + // In revealjs, Shift+Tab from after a tabset reaches the content of the // selected panel first, which skips the tabs after it. Start from the end // of the last panel instead, so Shift+Tab is the reverse of Tab. Only for - // keyboard focus: a click in a panel must keep that panel. + // Shift+Tab: a click in a panel must keep that panel. var backwardEntryHandler = function (event) { var from = event.relatedTarget; var tab = getTab(event.currentTarget); var map = tab && getTabsMap(tab); - if (!from || !map || !event.target.matches(":focus-visible")) return; + if (!from || !map || !shiftTabDown) return; var tabList = map.tabs[0].closest('[role="tablist"]'); var fromInside = Array.prototype.some.call(map.tabs, function (tab) { @@ -175,10 +197,12 @@ from.compareDocumentPosition(tabList) & Node.DOCUMENT_POSITION_PRECEDING; var last = map.tabs[map.tabs.length - 1]; + var lastContent = document.querySelector(last.hash); if ( fromInside || !fromAfter || - last.getAttribute("aria-selected") === "true" + !lastContent || + lastContent.contains(event.target) ) { return; } @@ -251,6 +275,7 @@ content.addEventListener("keydown", tabOrderHandler); tab.addEventListener("focusin", backwardEntryHandler); content.addEventListener("focusin", backwardEntryHandler); + trackShiftTab(); } }; diff --git a/tests/docs/playwright/revealjs/tabset-focus-order.qmd b/tests/docs/playwright/revealjs/tabset-focus-order.qmd index 4f0d61c4e6..d760994b65 100644 --- a/tests/docs/playwright/revealjs/tabset-focus-order.qmd +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -162,3 +162,21 @@ A [link after the details tabset](https://quarto.org). [link in tab I](https://quarto.org) ::: + +A [link after the editor tabset](https://quarto.org). + +## Empty selected panel + +::: {.panel-tabset} + +### Tab J + +Nothing to focus in this panel. + +### Tab K + +[link in tab K](https://quarto.org) + +::: + +A [link after the empty panel tabset](https://quarto.org). diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index 7ff4d769e3..13e15ddeb0 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -448,6 +448,25 @@ test.describe('tabset keyboard focus order', () => { await expect(tab(page, 'Tab E')).toHaveAttribute('aria-selected', 'true'); }); + test('clicking a text field in a panel from after the tabset keeps that panel', async ({ page }) => { + await gotoSlide(page, '#/control-handling-tab-in-a-tabset'); + await link(page, 'link after the editor tabset').focus(); + const editor = page.getByRole('textbox', { name: 'editor in tab H' }); + await editor.click(); + await expect(editor).toBeFocused(); + await expect(tab(page, 'Tab H')).toHaveAttribute('aria-selected', 'true'); + }); + + test('Shift+Tab from after a tabset with an empty selected panel reaches the last panel', async ({ page, browserName }) => { + await gotoSlide(page, '#/empty-selected-panel'); + await link(page, 'link after the empty panel tabset').focus(); + await expectTabOrder(page, browserName, [ + link(page, 'link in tab K'), + tab(page, 'Tab K'), + tab(page, 'Tab J'), + ], true); + }); + test('Tab is left to a panel control that handles it', async ({ page, browserName }) => { await gotoSlide(page, '#/control-handling-tab-in-a-tabset'); const editor = page.getByRole('textbox', { name: 'editor in tab H' }); From 8278c2964863d976ddf545d1ea503b98e08a2655 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 22 Oct 2026 13:55:59 +0200 Subject: [PATCH 07/10] Clear the revealjs Shift+Tab state on click and window blur The Shift+Tab state used to detect backward entry into a tabset was only cleared on keyup. A click while Shift+Tab was still held, after it had moved focus outside the tabset, was taken as backward entry: clicking a control in the first panel switched to the last tab and hid it. The state is now also cleared on pointerdown, before pointer focus, and on window blur, where the keyup may never reach the page. The blur case is a precaution, not covered by a test: Playwright cannot lose a keyup the way switching windows does. --- src/resources/formats/html/tabby/js/tabby.js | 15 ++++++++------- .../playwright/revealjs/tabset-focus-order.qmd | 2 +- .../integration/playwright/tests/revealjs.spec.ts | 13 +++++++++++++ 3 files changed, 22 insertions(+), 8 deletions(-) diff --git a/src/resources/formats/html/tabby/js/tabby.js b/src/resources/formats/html/tabby/js/tabby.js index 43d08a6a91..57eeaac9dd 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -169,13 +169,14 @@ }, true ); - document.addEventListener( - "keyup", - function () { - shiftTabDown = false; - }, - true - ); + // A click, or leaving the window (the keyup then goes elsewhere), ends it + // too + var clear = function () { + shiftTabDown = false; + }; + document.addEventListener("keyup", clear, true); + document.addEventListener("pointerdown", clear, true); + window.addEventListener("blur", clear); }; // In revealjs, Shift+Tab from after a tabset reaches the content of the diff --git a/tests/docs/playwright/revealjs/tabset-focus-order.qmd b/tests/docs/playwright/revealjs/tabset-focus-order.qmd index d760994b65..8afcc611ad 100644 --- a/tests/docs/playwright/revealjs/tabset-focus-order.qmd +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -163,7 +163,7 @@ A [link after the details tabset](https://quarto.org). ::: -A [link after the editor tabset](https://quarto.org). +A [link after the editor tabset](https://quarto.org) and [a second link](https://quarto.org). ## Empty selected panel diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index 13e15ddeb0..ec142ebb37 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -457,6 +457,19 @@ test.describe('tabset keyboard focus order', () => { await expect(tab(page, 'Tab H')).toHaveAttribute('aria-selected', 'true'); }); + test('clicking a panel control while Shift+Tab is held keeps that panel', async ({ page, browserName }) => { + await gotoSlide(page, '#/control-handling-tab-in-a-tabset'); + await link(page, 'a second link').focus(); + await page.keyboard.down('Shift'); + if (browserName === 'webkit') await page.keyboard.down('Alt'); + await page.keyboard.down('Tab'); + await expect(link(page, 'link after the editor tabset')).toBeFocused(); + const editor = page.getByRole('textbox', { name: 'editor in tab H' }); + await editor.click(); + await expect(editor).toBeFocused(); + await expect(tab(page, 'Tab H')).toHaveAttribute('aria-selected', 'true'); + }); + test('Shift+Tab from after a tabset with an empty selected panel reaches the last panel', async ({ page, browserName }) => { await gotoSlide(page, '#/empty-selected-panel'); await link(page, 'link after the empty panel tabset').focus(); From b13558c14b58e0cf0002d11095ac3cc250bc3d26 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 22 Oct 2026 14:14:34 +0200 Subject: [PATCH 08/10] Go to the end of a nested tabset with Shift+Tab in revealjs Shift+Tab into the previous panel went to its last focusable element. If that panel ends with a nested tabset whose selected tab is not the last one, that element is in the selected inner panel, so Shift+Tab from Outer B skipped Inner 2 and its content, which Tab does visit. The end of a panel ending with a nested tabset is now the end of that tabset's last panel, at any depth. The nested Shift+Tab test expected the shortened path and now expects the reverse of the Tab order. Adds a hidden link at the end of a panel to the fixture. The nested test was the one covering the check that skips hidden elements, and with the fix it passes without that check; a hidden element as last of a panel still needs it, otherwise Tab from the last visible element skips the next tab. --- src/resources/formats/html/tabby/js/tabby.js | 27 +++++++++++++------ .../revealjs/tabset-focus-order.qmd | 2 ++ .../playwright/tests/revealjs.spec.ts | 2 ++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/resources/formats/html/tabby/js/tabby.js b/src/resources/formats/html/tabby/js/tabby.js index 57eeaac9dd..c98101052a 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -87,9 +87,9 @@ 'a[href], button, input, textarea, select, summary,[tabindex]:not([tabindex="-1"])' ), ].filter( - // Shown elements only, as the browser skips the others: content of - // unselected panels (also of a nested tabset) is not rendered, and - // revealjs fragments not yet shown have `visibility: hidden` + // Shown elements only, as the browser skips the others: hidden content, + // like unselected panels (also of a nested tabset), is not rendered, + // and revealjs fragments not yet shown have `visibility: hidden` (el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden") && @@ -107,13 +107,24 @@ // Selects a tab and focuses the last focusable element of its panel, or // the tab itself if there is none. The panel content is hidden until the - // tab is selected. + // tab is selected. When the panel ends with a nested tabset, its end is + // the end of that tabset's last panel, as Tab would reach it. var focusEndOf = function (tab) { toggle(tab); - ( - getKeyboardFocusableElements(document.querySelector(tab.hash)).pop() || - tab - ).focus(); + var content = document.querySelector(tab.hash); + var last = getKeyboardFocusableElements(content).pop(); + if (!last) return tab.focus(); + + // Outermost tab or tab panel between the last element and this panel + var nestedTab; + for (var node = last; node && node !== content; node = node.parentElement) { + var role = node.getAttribute("role"); + if (role === "tab") nestedTab = node; + if (role === "tabpanel") nestedTab = getTab(node); + } + var nested = nestedTab && getTabsMap(nestedTab); + if (nested) return focusEndOf(nested.tabs[nested.tabs.length - 1]); + last.focus(); }; // In revealjs, Tab goes from a tab to its panel content and from the end diff --git a/tests/docs/playwright/revealjs/tabset-focus-order.qmd b/tests/docs/playwright/revealjs/tabset-focus-order.qmd index 8afcc611ad..55fb502d90 100644 --- a/tests/docs/playwright/revealjs/tabset-focus-order.qmd +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -39,6 +39,8 @@ A [link before the tabset](https://quarto.org). Content C with a [link in tab C](https://quarto.org). + + ### Tab D Content D with a [link in tab D](https://quarto.org). diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index ec142ebb37..5dc0fad803 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -417,6 +417,8 @@ test.describe('tabset keyboard focus order', () => { await link(page, 'link in outer B').focus(); await expectTabOrder(page, browserName, [ tab(page, 'Outer B'), + link(page, 'link in inner 2'), + tab(page, 'Inner 2'), link(page, 'link in inner 1'), tab(page, 'Inner 1'), link(page, 'link in outer A'), From 11f83d940da198c1390ee2fcda3d84a8994b147b Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 22 Oct 2026 14:17:27 +0200 Subject: [PATCH 09/10] Skip controls disabled by a fieldset in revealjs tabset panels A control inside `
` is disabled without a `disabled` attribute of its own, so it was kept as a focusable element of the panel. When it came first, Tab on the tab tried to focus it and focus stayed on the tab. Disabled controls are now detected with `:disabled`. --- src/resources/formats/html/tabby/js/tabby.js | 2 +- tests/docs/playwright/revealjs/tabset-focus-order.qmd | 2 ++ tests/integration/playwright/tests/revealjs.spec.ts | 6 ++++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/resources/formats/html/tabby/js/tabby.js b/src/resources/formats/html/tabby/js/tabby.js index c98101052a..cafc5e74f6 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -91,7 +91,7 @@ // like unselected panels (also of a nested tabset), is not rendered, // and revealjs fragments not yet shown have `visibility: hidden` (el) => - !el.hasAttribute("disabled") && + !el.matches(":disabled") && !el.getAttribute("aria-hidden") && el.getClientRects().length > 0 && window.getComputedStyle(el).visibility === "visible" diff --git a/tests/docs/playwright/revealjs/tabset-focus-order.qmd b/tests/docs/playwright/revealjs/tabset-focus-order.qmd index 55fb502d90..7cde51cd4e 100644 --- a/tests/docs/playwright/revealjs/tabset-focus-order.qmd +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -161,6 +161,8 @@ A [link after the details tabset](https://quarto.org). ### Tab I +
+ [link in tab I](https://quarto.org) ::: diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index 5dc0fad803..03df83c97e 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -482,6 +482,12 @@ test.describe('tabset keyboard focus order', () => { ], true); }); + test('Tab skips controls disabled by their fieldset in a panel', async ({ page, browserName }) => { + await gotoSlide(page, '#/control-handling-tab-in-a-tabset'); + await tab(page, 'Tab I').click(); + await expectTabOrder(page, browserName, [link(page, 'link in tab I')]); + }); + test('Tab is left to a panel control that handles it', async ({ page, browserName }) => { await gotoSlide(page, '#/control-handling-tab-in-a-tabset'); const editor = page.getByRole('textbox', { name: 'editor in tab H' }); From cb6d7cae66f982863c2e50be22d33d77067077b3 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 22 Oct 2026 14:22:59 +0200 Subject: [PATCH 10/10] Treat other tab widgets in a revealjs tabset panel as content Going to the end of a panel recursed into any `role="tab"` ancestor of its last focusable element. A tab widget that is not a tabby tabset, such as one with button tabs (no `hash`) or Bootstrap tabs, made it throw or toggle that widget's tabs. It now recurses only into tabs set up by tabby, and other tab widgets are ordinary panel content. Elements with `tabindex="-1"` are also left out of a panel's focusable elements: native links and buttons matched the selector even then, although the browser skips them, so the roving tabindex of such a widget made Shift+Tab land on its unselected tab. --- src/resources/formats/html/tabby/js/tabby.js | 13 ++++++++--- .../revealjs/tabset-focus-order.qmd | 22 +++++++++++++++++++ .../playwright/tests/revealjs.spec.ts | 12 ++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/resources/formats/html/tabby/js/tabby.js b/src/resources/formats/html/tabby/js/tabby.js index cafc5e74f6..cdfa1035ca 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -91,6 +91,7 @@ // like unselected panels (also of a nested tabset), is not rendered, // and revealjs fragments not yet shown have `visibility: hidden` (el) => + el.getAttribute("tabindex") !== "-1" && !el.matches(":disabled") && !el.getAttribute("aria-hidden") && el.getClientRects().length > 0 && @@ -98,6 +99,9 @@ ); }; + // Tabs set up by tabby, as opposed to other tab widgets in a panel + var tabbyTabs = new WeakSet(); + // The tab of a tab or of a tab panel var getTab = function (tabOrPanel) { return tabOrPanel.getAttribute("role") === "tab" @@ -115,12 +119,14 @@ var last = getKeyboardFocusableElements(content).pop(); if (!last) return tab.focus(); - // Outermost tab or tab panel between the last element and this panel + // Outermost tabby tab or tab panel between the last element and this + // panel; other tab widgets are ordinary panel content var nestedTab; for (var node = last; node && node !== content; node = node.parentElement) { var role = node.getAttribute("role"); - if (role === "tab") nestedTab = node; - if (role === "tabpanel") nestedTab = getTab(node); + var nodeTab = + role === "tab" ? node : role === "tabpanel" ? getTab(node) : null; + if (nodeTab && tabbyTabs.has(nodeTab)) nestedTab = nodeTab; } var nested = nestedTab && getTabsMap(nestedTab); if (nested) return focusEndOf(nested.tabs[nested.tabs.length - 1]); @@ -266,6 +272,7 @@ // Add roles tab.setAttribute("role", "tab"); + tabbyTabs.add(tab); tab.setAttribute("aria-controls", content.id); tab.closest("li").setAttribute("role", "presentation"); content.setAttribute("role", "tabpanel"); diff --git a/tests/docs/playwright/revealjs/tabset-focus-order.qmd b/tests/docs/playwright/revealjs/tabset-focus-order.qmd index 7cde51cd4e..f36d957de3 100644 --- a/tests/docs/playwright/revealjs/tabset-focus-order.qmd +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -184,3 +184,25 @@ Nothing to focus in this panel. ::: A [link after the empty panel tabset](https://quarto.org). + +## Other tab widget in a panel + +::: {.panel-tabset} + +### Tab L + +[link in tab L](https://quarto.org) + +```{=html} +
+ + +
+
Widget content.
+``` + +### Tab M + +[link in tab M](https://quarto.org) + +::: diff --git a/tests/integration/playwright/tests/revealjs.spec.ts b/tests/integration/playwright/tests/revealjs.spec.ts index 03df83c97e..8450b30ca8 100644 --- a/tests/integration/playwright/tests/revealjs.spec.ts +++ b/tests/integration/playwright/tests/revealjs.spec.ts @@ -426,6 +426,18 @@ test.describe('tabset keyboard focus order', () => { ], true); }); + test('Shift+Tab treats another tab widget in a panel as panel content', async ({ page, browserName }) => { + await gotoSlide(page, '#/other-tab-widget-in-a-panel'); + await tab(page, 'Tab M').click(); + await link(page, 'link in tab M').focus(); + await expectTabOrder(page, browserName, [ + tab(page, 'Tab M'), + page.getByRole('tab', { name: 'widget tab 1' }), + link(page, 'link in tab L'), + tab(page, 'Tab L'), + ], true); + }); + test('Tab reaches details summaries and skips unrevealed fragments in panels', async ({ page, browserName }) => { await gotoSlide(page, '#/details-and-fragments-in-a-tabset'); await tab(page, 'Tab E').focus();