feat(core): TabList reads its role and speaks the ARIA tabs pattern - #5349
Draft
cixzhang wants to merge 1 commit into
Draft
feat(core): TabList reads its role and speaks the ARIA tabs pattern#5349cixzhang wants to merge 1 commit into
cixzhang wants to merge 1 commit into
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
cixzhang
force-pushed
the
tablist-scroll-overflow
branch
2 times, most recently
from
August 23, 2026 01:28
0628b8e to
34f5c6d
Compare
The strip could only be a nav landmark with aria-current, so a consumer with real panels below it had to reimplement it to get role=tablist, aria-selected and aria-controls. No new prop: TabList declares role?: AriaRole and reads it, the way LayoutHeader and LayoutPanel already declare theirs. role="tablist" asks for the tabs pattern, any other role passes through untouched, and left unset the strip picks from what it renders — tabs when it holds nothing but tabs, the nav landmark when it holds a menu, a link, or anything else. That changes the default for a strip of plain tabs. The pick reads the rendered DOM rather than children, so a menu behind a conditional, inside a map, or wrapped in a consumer's own component counts, and it follows the strip if the contents change later. It settles because the markers it reads do not depend on the answer, and the wrapper stays a <nav> — role="none" takes the landmark away — so a change of pattern never remounts the tabs. An href is only ignored where the role was asserted, and so is the warning for a tab that controls nothing; either panelId or a hand-written aria-controls satisfies it, and a hand-written one is never overwritten.
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.
What
TabListcan speak the WAI-ARIA tabs pattern instead of the navigation one — and there is no new prop for it.TabListdeclaresrole?: AriaRoleand reads it, the wayLayoutHeader,LayoutContentandLayoutPanelalready declare and document theirs:role"tablist"role="tablist"on the strip,role="tab"andaria-selectedon the tabs,aria-controlsfrom a newpanelIdonTab<nav>landmark witharia-currentwhen it holds anything elseStacked on #5348 — review that one first; this branch contains it.
Why no new prop
rolealready reaches the DOM:{...restProps}spreads it onto the wrapper, so a caller can passrole="tablist"today and get a tablist whose children are still<button>s witharia-current— invalid markup, noaria-selected, nav-style arrow keys, and no warning. Reading the role turns that silent breakage into the correct behaviour, and declaring it explicitly is what puts it in the type, the prop table and the docs, where a behaviour keyed on an anonymous passthrough would be invisible.This changes the default, and that is the risky part
A strip of plain tabs is a
<nav>landmark today and becomes a tablist. Concretely, for a caller who changes nothing:aria-selected) rather than the current item (aria-current)<nav>, withrole="none"A strip that really is page navigation keeps the landmark by saying so:
role="navigation". In this repo the change moved 20 existing tests, which is the honest measure of the blast radius — the changeset is filed[breaking]for that reason.Two things the strip will not do to you on its own
A tab with an
hrefkeeps navigating. Anhrefis only ignored whererole="tablist"was asked for explicitly, and then a development warning says so. Left to pick, a strip holding a link stays navigation — the component choosing a pattern is not licence to break a caller's link, and it is the one case where the default change could have been a functional regression rather than a semantic one.A tab that controls nothing is only worth mentioning to someone who asked for the pattern. That warning is likewise limited to the explicit role: a strip the component made a tablist out of tabs written for navigation has done nothing wrong, and a warning that fires on a correct setup is worse than a missing one. Either
panelIdor anaria-controlsyou wrote yourself satisfies it —Tabalready spreads yours to the DOM, so both spellings count and a hand-written one is never overwritten. Nothing validates that the target exists: a panel may mount later.A
panelIdon a strip that turns out not to be a tablist is dropped without a word, deliberately: the pattern settles in a layout effect, after a tab's own effects have run, so a tab cannot tell "not a tablist" from "not a tablist yet" — measured, a warning there fires on every correct strip on mount. The prop doc says so instead.aria-controlsis emitted only when apanelId(or your ownaria-controls) is supplied. Generating one would ship a dangling reference, which axe rates critical (aria-valid-attr-value); measured in Chromium on three hand-built tablists, dangling → 1 critical violation, resolved → clean, absent → clean.The pick is made from the rendered DOM, and it settles
It reads the strip's rendered children, not
children: a menu behind a conditional, inside a.map, or wrapped in a component of your own is invisible to introspection, and a check that can be walked around silently is worse than none. So it has to run in production, and it has to be cheap and stable. Measured in Chromium:MutationObserveron the strip,{childList: true}— no subtree, no attributesrole="tablist", so there is no flash of the other patternIt cannot feed itself: the markers it reads —
data-tab-valueon a tab, thehrefa link tab renders — are the same whichever pattern wins, so the answer is a pure function of the child set and the second callback in a flip recomputes the same value and bails.childListalone is sufficient because neither marker can appear or disappear in place: a tab that gains anhrefbecomes an anchor, which is an element swap.And the wrapper stays a
<nav>in both patterns, dropping its landmark withrole="none"rather than becoming a<div>. That is the difference between changing an attribute and remounting the whole subtree, which would throw away focus, scroll position and every child's state each time the strip's contents change.<nav role="none">is axe-clean and leaves no landmark and no extra node in the tree — it reads better than the<div>it replaces, which contributed ageneric.Accessibility trees, from Chromium
No role, only tabs — the new default:
No role, a menu in the strip:
role="tablist":role="navigation":axe: no violations on any of the four.
Testing
24 tests across the pattern, nine of which fail on the parent branch and pass here; the rest are regression guards that must pass on both, and do. They cover: the role being read, the pick going both ways at runtime (and the tab keeping its DOM identity across it), other roles passing through, both spellings of the panel link, and the warning when neither is there.
Keyboard, driven for real in Chromium: Tab enters the strip onto the selected tab; ArrowRight moves; Enter selects and reveals the panel; Tab leaves the strip and lands on it. ArrowDown scrolls the page from inside a tablist and still moves between items under
role="navigation".The repo's own accessibility audit, against a built Storybook: 0 new violations, and 3 baseline entries resolved —
landmark-uniqueon the TabList, Toolbar and edge-compensation stories, where several strips were navigation landmarks all named "Tabs" on one page. Those entries are removed from the baseline here. Onelandmark-uniqueentry remains, on the story whose strips hold action buttons and therefore stay navigation.Full
packages/coresuite, typecheck and lint are green. (DateInputTouchand oneSideNavtest fail under the full parallel run on the parent branch too, and pass in isolation — pre-existing flakiness, untouched by this branch.)A note on scope for the axe runs: they are scoped to the story root. At whole-document scope axe also reports its
regionrule, because a story is not inside a landmark — the repo's own accessibility audit disables that rule for exactly this reason.