Skip to content

Fix revealjs tabset keyboard focus order - #15155

Merged
cderv merged 10 commits into
mainfrom
fix/issue-14970
Oct 23, 2026
Merged

cderv merged 10 commits into
mainfrom
fix/issue-14970

Conversation

@cderv

@cderv cderv commented Oct 22, 2026

Copy link
Copy Markdown
Member

In revealjs, tabset tabs and the focusable content of their panels get positive tabindex values (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 from 1, 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 increasing tabindex values were added to get back the order tab A, content of A, tab B, content of B.

// Add roles to tabs. provide dynanmic tab indexes if we are within reveal
var contentTabindexes =
window.document.body.classList.contains("reveal-viewport");
var nextTabindex = 1;
Array.prototype.forEach.call(tabs, function (tab) {
if (contentTabindexes) {
tab.setAttribute("tabindex", "" + nextTabindex++);
} else {
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++);
});
}

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: summary and not details (folded code), not hidden content, not links in fragments not yet shown (visibility: hidden), and not controls disabled by their fieldset. 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 tabindex with arrow keys switching tabs, the ARIA tabs pattern used outside revealjs since #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):

  • referenced the GitHub issue this PR closes
  • updated the appropriate changelog in the PR
  • ensured the present test suite passes
  • added new tests
  • created a separate documentation PR in Quarto's website repo and linked it to this PR
AI-assisted PR
  • AI tool used: Claude Code, Codex for code review
  • Codebase grounding: local clone
  • Human review: I have reviewed, tested, and verified the AI-generated content before submitting.

Note: autonomous AI agents submitting PRs without human oversight are not permitted — see the Code of Conduct.

@posit-snyk-bot

posit-snyk-bot commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

cderv added 10 commits October 22, 2026 18:25
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
cderv merged commit c752176 into main Oct 23, 2026
52 checks passed
@cderv
cderv deleted the fix/issue-14970 branch October 23, 2026 09:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

revealjs tabset tabs get positive tabindex values

2 participants