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) + +