Repository navigation
Conversation
Collaborator
✅ Snyk checks have passed. No issues have been found so far.
💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse. |
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.
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.
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.
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.
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.
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.
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.
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.
A control inside `<fieldset disabled>` 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`.
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.
cderv
force-pushed
the
fix/issue-14970
branch
from
October 22, 2026 16:25
9e0792e to
cb6d7ca
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
In
revealjs, tabset tabs and the focusable content of their panels get positivetabindexvalues (1,2, ...). Positive values come before every other element in the Tab order, so a link placed before a tabset on a slide is reached only after all the tabs and their content. Each tabset also counts from1, so two tabsets on the same slide are visited interleaved.The symptom first reported in #14970, Tab on an earlier slide reaching a tab on a later one, was #14795 and is fixed by #15010. #15007 brought back upstream tabby behavior outside
revealjs(#730) and fixed Home/End also changing slide. What remains is the order within a slide, fixed here.Root cause
Our copy of tabby was changed in 2021 so that arrow keys keep moving slides in
revealjs: tabs are selected as soon as they get focus, and all tabs are in the Tab order. As tabs come before all panels in the DOM, Tab then goes from tab to tab and the content of a panel is not reachable. The increasingtabindexvalues were added to get back the order tab A, content of A, tab B, content of B.quarto-cli/src/resources/formats/html/tabby/js/tabby.js
Lines 315 to 336 in 83097c1
Fix
Tabs keep
tabindex="0"and arrow keys still change slides. The same order now comes from handling Tab on tabs and panels: Tab on a tab goes to the first focusable element of its panel, and from the last one to the next tab. Shift+Tab goes the other way, selecting the previous tab first. Anything else is left to the browser, so focus enters and leaves the tabset in document order.Shift+Tab from content after a tabset would reach the selected panel first and skip the tabs after it. In that case the last tab is selected and focus starts from the end of its panel, so going backwards is the reverse of going forwards. Clicks don't trigger this.
The first and last focusable elements of a panel have to match what the browser actually focuses:
summaryand notdetails(folded code), not hidden content, not links in fragments not yet shown (visibility: hidden), and not controls disabled by theirfieldset. When a panel ends with a nested tabset, its end is the end of that tabset's last panel. A control that handles Tab itself, like a code editor, keeps it.Alternative considered: a roving
tabindexwith arrow keys switching tabs, the ARIA tabs pattern used outsiderevealjssince #15007. It was not used because clicking a tab leaves focus on it, so a presenter using arrow keys or a clicker would switch tabs instead of slides. This was asked on the issue without feedback.Fixes #14970
Checklist
I have (if applicable):
AI-assisted PR
Note: autonomous AI agents submitting PRs without human oversight are not permitted — see the Code of Conduct.