Skip to content

feat(core): TabList reads its role and speaks the ARIA tabs pattern - #5349

Merged
cixzhang merged 1 commit into
mainfrom
tablist-mode
Aug 24, 2026
Merged

feat(core): TabList reads its role and speaks the ARIA tabs pattern#5349
cixzhang merged 1 commit into
mainfrom
tablist-mode

Conversation

@cixzhang

@cixzhang cixzhang commented Aug 22, 2026

Copy link
Copy Markdown
Contributor

What

TabList can speak the WAI-ARIA tabs pattern when a caller asks for it — and there is no new prop for the asking. TabList declares role?: AriaRole and reads it, the way LayoutHeader, LayoutContent and LayoutPanel already declare and document theirs:

role what the strip is
"tablist" the tabs pattern: role="tablist" on the strip, role="tab" and aria-selected on the tabs, aria-controls from a new panelId on Tab
unset unchanged — the <nav> landmark with aria-current it has always been
anything else passed through to the element, untouched, exactly as before

Stacked on #5348 — review that one first; this branch contains it.

Why no new prop

role already reaches the DOM: {...restProps} spreads it onto the wrapper, so a caller can pass role="tablist" today and get a tablist whose children are still <button>s with aria-current — invalid markup, no aria-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.

Nothing changes for anyone who does not ask

A caller who passes no role gets exactly what they got before: same element, same classes, same aria-current, same both-axis arrow keys, same pixels. Measured rather than asserted — seven configurations of a no-role strip (plain, divider + lg + fill, with a menu, with href tabs, icon-only sm, overflow="visible", with action buttons) serialise byte-identically against this PR's base, 31,326 bytes each. Against main the DOM differs only where #5348 changed it, and the role and ARIA attribute set is identical there too.

Under the asserted role the strip takes only the horizontal arrows and leaves ArrowUp and ArrowDown to the page, because a tablist reports itself as horizontal. ArrowLeft, ArrowRight, Home, End, Tab and the roving tab stop are unchanged.

Two things the asserted role tells you about

A tab with an href stops navigating, and a development warning says so. Activating a tab swaps a panel in place, so a tab that navigates is a false statement — but it is also the caller's link, so it is only ever ignored where the pattern was asked for by name.

A tab that controls nothing is asked for a panelId. Either panelId or an aria-controls you wrote yourself satisfies it — Tab already 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.

aria-controls is emitted only when an id 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.

A menu or any other non-tab in a tablist strip is invalid markup, and warns too — read off the rendered DOM in development, so a menu behind a conditional, inside a .map, or wrapped in a component of your own is still caught.

Testing

Accessibility trees read from Chromium's own tree (CDP), not inferred from the DOM:

landmark widget wrapper tabs
no role navigation("Tabs") none <nav> 3 buttons, aria-current="true" on the selected one
role="tablist" + panelId none tablist("Project views") <div> 3 tabs, aria-selected, aria-controls, 3 tabpanels
role="tablist", no panelId none tablist("Project views") <div> 3 tabs, no aria-controls, one warning each
role="navigation" navigation("Project sections") none <nav role="navigation"> 3 buttons, aria-current="true"

The no-role row is identical, row for row, to the same story on main.

axe is clean on every one of them, discounting region, which the repo's own audit disables for story isolation (.github/scripts/accessibility-audit.js:54).

86 unit tests green across TabList and the edge-compensation suite; every pre-existing TabList test is untouched — the test file's diff against the base is additions only. Typecheck and lint clean.

@vercel

vercel Bot commented Aug 22, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
astryx Ready Ready Preview Aug 24, 2026 6:10am

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Meta Open Source bot. label Aug 22, 2026
@cixzhang
cixzhang force-pushed the tablist-scroll-overflow branch 2 times, most recently from 0628b8e to 34f5c6d Compare August 23, 2026 01:28
@cixzhang cixzhang changed the title feat(core): TabList can speak the ARIA tabs pattern feat(core): TabList reads its role and speaks the ARIA tabs pattern Aug 23, 2026
@cixzhang
cixzhang force-pushed the tablist-scroll-overflow branch from b384572 to 7b025fa Compare August 23, 2026 18:12
@cixzhang cixzhang closed this Aug 23, 2026
@cixzhang cixzhang reopened this Aug 23, 2026
@cixzhang
cixzhang force-pushed the tablist-scroll-overflow branch from de952fe to 27a181e Compare August 23, 2026 23:44

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — reading role is the right shape, and that half is an approve from me.

I'd rather land it alone. Picking the pattern when no role is given is a second decision, and not the review's to settle: every caller passes no role, so they all become tablists — and none pass panelId, so a screen reader announces a selected tab with no panel to move to. The explicit role answers a problem the body states; I can't find one written down for the default change, and the 20 moved tests and the [breaking] are all its.

Send the explicit-role half on its own and it lands; the auto pick wants its own PR, with that reason in the body.

[Reviewed by Robohands]

Comment thread packages/core/src/TabList/TabList.tsx Outdated
// else does not get to be a tablist behind the caller's back.
const isTabListAsserted = role === 'tablist';
const isPatternAuto = role === undefined;
const canAutoTabs = useAutoPattern(stripRef, isPatternAuto);

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, this is the default change — every caller passes no role today. Probably wants its own PR.

Comment thread packages/core/src/TabList/Tab.tsx Outdated
'a tab in a role="tablist" TabList controls nothing: pass panelId with ' +
'the id of the panel it opens, so assistive technology can associate ' +
'the two.',
isTabRole && !tabListCtx.isPatternAuto && controls == null,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suppressed here, so an auto-picked tablist never asks for a panel. Those are the strips that have none.

@cixzhang cixzhang left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — this is the split the earlier review asked for, and the explicit-role half stands on its own. Approve from me once you undraft.

[Reviewed by Robohands]

'a tab in a role="tablist" TabList controls nothing: pass panelId with ' +
'the id of the panel it opens, so assistive technology can associate ' +
'the two.',
isTabRole && controls == null,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, panelId with no role is dropped silently. The other three mistakes here warn.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taken — it warns now: panelId outside a role="tablist" strip says so and points at the role. Tab.tsx:295.

TabList declares role?: AriaRole and reads it. role="tablist" gives the
WAI-ARIA tabs pattern -- role="tab", aria-selected, aria-controls from a
new panelId on Tab, horizontal-only arrows, and an href ignored with a dev
warning. Any other role passes through to the element untouched.

An omitted role is unchanged: the <nav> landmark with aria-current that
TabList has always rendered.
@github-actions

Copy link
Copy Markdown
Contributor

PR Analysis Report

📚 Storybook Preview

View Storybook for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

🧪 Sandbox Preview

View Sandbox for this PR
GitHub Pages may take up to a minute to hydrate after deploy.

Modified Components

TabList (@astryxdesign/core) · View in Storybook
Metric Before After Delta
Bundle Size (ESM) N/A N/A N/A
Lines of Code N/A 1166 -
Complexity N/A Very High (90) -

Bundle Size Summary

Package Size (ESM) Size (CJS) Gzipped
@astryxdesign/core N/A 4.8KB 1.2KB

Accessibility Audit

Status: No accessibility violations detected.


Generated by PR Enrichment workflow | Storybook | Sandbox | View full report

@cixzhang
cixzhang marked this pull request as ready for review August 24, 2026 06:23
@cixzhang
cixzhang merged commit 022d453 into main Aug 24, 2026
22 checks passed
@cixzhang
cixzhang deleted the tablist-mode branch August 24, 2026 06:23
github-actions Bot added a commit that referenced this pull request Aug 24, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Meta Open Source bot.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant