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..cdfa1035ca 100644 --- a/src/resources/formats/html/tabby/js/tabby.js +++ b/src/resources/formats/html/tabby/js/tabby.js @@ -84,13 +84,150 @@ 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( - (el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-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.getAttribute("tabindex") !== "-1" && + !el.matches(":disabled") && + !el.getAttribute("aria-hidden") && + el.getClientRects().length > 0 && + window.getComputedStyle(el).visibility === "visible" ); }; + // 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" + ? 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. 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); + var content = document.querySelector(tab.hash); + var last = getKeyboardFocusableElements(content).pop(); + if (!last) return tab.focus(); + + // 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"); + 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]); + last.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. + var tabOrderHandler = function (event) { + // 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 = getTab(current); + 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; + event.preventDefault(); + focusEndOf(previous); + return; + } 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(); + }; + + // 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 + ); + // 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 + // 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 + // 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 || !shiftTabDown) 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]; + var lastContent = document.querySelector(last.hash); + if ( + fromInside || + !fromAfter || + !lastContent || + lastContent.contains(event.target) + ) { + return; + } + + focusEndOf(last); + }; + /** * Remove roles and attributes from a tab and its content * @param {Node} tab The tab @@ -105,6 +242,10 @@ // remove event listener 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"); @@ -131,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"); @@ -145,8 +287,15 @@ 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); + tab.addEventListener("focusin", backwardEntryHandler); + content.addEventListener("focusin", backwardEntryHandler); + trackShiftTab(); + } }; /** @@ -322,27 +471,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..f36d957de3 100644 --- a/tests/docs/playwright/revealjs/tabset-focus-order.qmd +++ b/tests/docs/playwright/revealjs/tabset-focus-order.qmd @@ -28,3 +28,181 @@ 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) + +::: + +## 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). + +## Control handling Tab in a tabset + +::: {.panel-tabset} + +### Tab H + +```{=html} + + +``` + +### Tab I + +
+ +[link in tab I](https://quarto.org) + +::: + +A [link after the editor tabset](https://quarto.org) and [a second link](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). + +## 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 8381535e91..8450b30ca8 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 }) => { @@ -317,3 +317,195 @@ 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(); + }; + + 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 expectTabOrder(page, browserName, [link(page, 'link on slide 1')]); + }); + + 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 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 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'), + ]); + }); + + 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 2'), + tab(page, 'Inner 2'), + link(page, 'link in inner 1'), + tab(page, 'Inner 1'), + link(page, 'link in outer A'), + tab(page, 'Outer A'), + ], 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(); + 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'), + ]); + }); + + 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('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('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(); + await expectTabOrder(page, browserName, [ + link(page, 'link in tab K'), + tab(page, 'Tab K'), + tab(page, 'Tab J'), + ], 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' }); + await editor.focus(); + await page.keyboard.press(tabKey(browserName)); + await expect(editor).toBeFocused(); + await expect(editor).toHaveValue('\t'); + }); +});