Skip to content

Commit 6f767bc

Browse files
committed
feat(core): TabList reads its role and speaks the ARIA tabs pattern
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.
1 parent 34f5c6d commit 6f767bc

10 files changed

Lines changed: 754 additions & 87 deletions

File tree

.changeset/tablist-aria-role.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@astryxdesign/core': minor
3+
---
4+
5+
[breaking] TabList: the strip now speaks the WAI-ARIA tabs pattern — `role="tablist"` on the strip, `role="tab"` and `aria-selected` on the tabs, and `aria-controls` pointing at the panel each tab opens, from a new `panelId` prop on `Tab`. There is no new prop for it: `TabList` declares `role?: AriaRole` and reads it. `role="tablist"` asks for the pattern, any other role passes through to the element untouched, and **left unset the strip picks for itself** — the tabs pattern when it holds nothing but tabs, the navigation landmark when it holds anything else. The keyboard behaviour the pattern asks for was already there: arrows move between tabs, Tab leaves the strip.
6+
7+
**This changes the default.** A strip of plain tabs is a `<nav>` landmark today and becomes a tablist: the selected tab is announced as a selected tab rather than the current item, `aria-current` gives way to `aria-selected`, and — because a tablist reports itself as horizontal — ArrowUp and ArrowDown stop moving between tabs and scroll the page instead. ArrowLeft, ArrowRight, Home, End, Tab and the roving tab stop are unchanged, as is everything the strip looks like. A strip that really is page navigation keeps the landmark by saying so with `role="navigation"`.
8+
9+
Two things the strip will not do to you on its own. A tab with an `href` navigates, so a strip holding one stays navigation — an `href` is only ignored where `role="tablist"` was asked for explicitly, and then a development warning says so. And a tab that controls nothing is only worth mentioning to a consumer who asked for the pattern, so that warning too is limited to the explicit role; either `panelId` or an `aria-controls` you wrote yourself satisfies it, and a hand-written one is never overwritten. `aria-controls` is emitted only when you supply the id: pointing at a panel that does not exist is an invalid attribute value, which is worse than saying nothing.
10+
11+
The pick is made from the rendered DOM rather than from `children`, so a menu behind a conditional, inside a `.map`, or wrapped in a component of your own still counts, and it follows the strip if its contents change later — without remounting the tabs.
12+
13+
@cixzhang

.github/a11y-baseline.json

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -893,11 +893,6 @@
893893
"impact": "moderate",
894894
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/landmark-unique?application=playwright"
895895
},
896-
{
897-
"key": "TabList::Size Variants::landmark-unique",
898-
"impact": "moderate",
899-
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/landmark-unique?application=playwright"
900-
},
901896
{
902897
"key": "Table::In Card Densities::color-contrast",
903898
"impact": "serious",
@@ -1048,16 +1043,6 @@
10481043
"impact": "serious",
10491044
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/color-contrast?application=playwright"
10501045
},
1051-
{
1052-
"key": "Toolbar::Composition: Tab Navigation::landmark-unique",
1053-
"impact": "moderate",
1054-
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/landmark-unique?application=playwright"
1055-
},
1056-
{
1057-
"key": "ToolbarEdgeCompensation::Tabs in toolbar (all sizes)::landmark-unique",
1058-
"impact": "moderate",
1059-
"helpUrl": "https://dequeuniversity.com/rules/axe/4.12/landmark-unique?application=playwright"
1060-
},
10611046
{
10621047
"key": "useContainerReveal::Inverted Conceal::color-contrast",
10631048
"impact": "serious",

apps/storybook/stories/TabList.stories.tsx

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@ const meta: Meta<typeof TabList> = {
2222
export default meta;
2323
type Story = StoryObj<typeof TabList>;
2424

25+
/**
26+
* With no `role`, a strip of nothing but tabs speaks the WAI-ARIA tabs
27+
* pattern: `role="tablist"` / `role="tab"` and `aria-selected`.
28+
*/
2529
export const Default: Story = {
2630
args: {
2731
size: 'md',
@@ -38,6 +42,32 @@ export const Default: Story = {
3842
},
3943
};
4044

45+
/**
46+
* A strip that really is page navigation says so with `role="navigation"`,
47+
* and stays a `<nav>` landmark marking the current item with `aria-current`
48+
* however few or many tabs it holds.
49+
*/
50+
export const NavigationLandmark: Story = {
51+
render: () => {
52+
const [value, setValue] = useState('overview');
53+
return (
54+
<TabList
55+
value={value}
56+
onChange={setValue}
57+
role="navigation"
58+
aria-label="Project sections">
59+
<Tab value="overview" label="Overview" />
60+
<Tab value="activity" label="Activity" />
61+
<Tab value="members" label="Members" />
62+
</TabList>
63+
);
64+
},
65+
};
66+
67+
/**
68+
* A menu is not a tab, so a strip holding one stays a `<nav>` landmark with
69+
* `aria-current` — the tabs pattern would be invalid markup here.
70+
*/
4171
export const WithMenu: Story = {
4272
args: {
4373
size: 'md',
@@ -194,6 +224,11 @@ export const IconOnly: Story = {
194224
* Demonstrates a common page header pattern: large tab list items on the left
195225
* with action buttons on the right, separated by a full-width divider underneath.
196226
*/
227+
/**
228+
* Action buttons rendered among the tabs are not tabs, so the strip keeps the
229+
* navigation pattern. Put them outside the `TabList` if you want the tabs
230+
* pattern as well.
231+
*/
197232
export const WithActions: Story = {
198233
render: () => {
199234
const [value, setValue] = useState('all');
@@ -404,3 +439,62 @@ export const OverflowNone: Story = {
404439
);
405440
},
406441
};
442+
443+
/**
444+
* `role="tablist"` asks for the WAI-ARIA tabs pattern: `role="tablist"` /
445+
* `role="tab"`, `aria-selected`, and each tab pointing at the panel it
446+
* controls. A screen reader announces "tab 2 of 3, selected" and can move to
447+
* the panel it opens. A strip of nothing but tabs reaches the same pattern on
448+
* its own — the explicit role is how you keep it when the strip also holds
449+
* something else, and how you ask to be warned when it does.
450+
*/
451+
export const TabsPattern: Story = {
452+
render: () => {
453+
const [value, setValue] = useState('overview');
454+
const panels = {
455+
overview: 'Everything at a glance.',
456+
activity: 'What happened recently.',
457+
members: 'Who has access.',
458+
};
459+
return (
460+
<div style={{display: 'grid', gap: '12px', maxWidth: '400px'}}>
461+
<TabList
462+
value={value}
463+
onChange={setValue}
464+
role="tablist"
465+
aria-label="Project views"
466+
hasDivider>
467+
<Tab
468+
value="overview"
469+
label="Overview"
470+
id="tab-overview"
471+
panelId="panel-overview"
472+
/>
473+
<Tab
474+
value="activity"
475+
label="Activity"
476+
id="tab-activity"
477+
panelId="panel-activity"
478+
/>
479+
<Tab
480+
value="members"
481+
label="Members"
482+
id="tab-members"
483+
panelId="panel-members"
484+
/>
485+
</TabList>
486+
{Object.entries(panels).map(([key, text]) => (
487+
<div
488+
key={key}
489+
id={`panel-${key}`}
490+
role="tabpanel"
491+
aria-labelledby={`tab-${key}`}
492+
tabIndex={0}
493+
hidden={key !== value}>
494+
{text}
495+
</div>
496+
))}
497+
</div>
498+
);
499+
},
500+
};

packages/core/src/Layout/__tests__/edgeCompensation.test.tsx

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,17 @@ describe('Edge Compensation', () => {
6464
<Tab value="tab1" label="Tab 1" />
6565
</TabList>,
6666
);
67-
const tab = screen.getByRole('button', {name: 'Tab 1'});
67+
const tab = screen.getByRole('tab', {name: 'Tab 1'});
6868
expect(tab).toHaveAttribute(EDGE_COMP_ATTR);
6969
});
7070

7171
it('applies edge comp attribute to TabList wrapper', () => {
72-
render(
72+
const {container} = render(
7373
<TabList value="" onChange={() => {}} aria-label="Tabs">
7474
<Tab value="tab1" label="Tab 1" />
7575
</TabList>,
7676
);
77-
const nav = screen.getByRole('navigation', {name: 'Tabs'});
78-
expect(nav).toHaveAttribute(EDGE_COMP_ATTR);
77+
expect(container.querySelector('nav')).toHaveAttribute(EDGE_COMP_ATTR);
7978
});
8079
});
8180

packages/core/src/TabList/Tab.doc.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,13 @@ export const docs = {
4040
name: 'href',
4141
type: 'string',
4242
description:
43-
'URL to navigate to; when provided, the tab renders as an anchor element.',
43+
'URL to navigate to; when provided, the tab renders as an anchor element and keeps the strip on the navigation pattern. Ignored in a TabList given an explicit role="tablist".',
44+
},
45+
{
46+
name: 'panelId',
47+
type: 'string',
48+
description:
49+
'Id of the panel this tab controls, wired up as aria-controls where the TabList speaks the tabs pattern. Put the same id on the panel element. No effect under the navigation pattern, including where the strip picked that pattern because it holds something that is not a tab.',
4450
},
4551
{
4652
name: 'as',
@@ -138,6 +144,12 @@ export const docsZh = {
138144
type: 'string',
139145
description: '要导航到的 URL;提供时,标签渲染为锚点元素。',
140146
},
147+
{
148+
name: 'panelId',
149+
type: 'string',
150+
description:
151+
'Id of the panel this tab controls, wired up as aria-controls where the TabList speaks the tabs pattern. Put the same id on the panel element. No effect under the navigation pattern, including where the strip picked that pattern because it holds something that is not a tab.',
152+
},
141153
{
142154
name: 'as',
143155
type: 'LinkComponentType',

packages/core/src/TabList/Tab.tsx

Lines changed: 68 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@
77
* @input Uses React, StyleX, TabListContext
88
* @output Exports Tab component and TabProps type
99
* @position Core tab item; renders as button or anchor in navigation with a
10-
* divider-overlay selected indicator
10+
* divider-overlay selected indicator. Where the TabList speaks the tabs
11+
* pattern it is a button with role="tab".
1112
*
1213
* SYNC: When modified, update:
1314
* - /packages/core/src/TabList/TabList.doc.mjs
@@ -35,6 +36,7 @@ import {tabScope} from './tab.markers.stylex';
3536
import {useLinkComponent} from '../Link/useLinkComponent';
3637
import type {LinkComponentType} from '../Link/types';
3738
import {mergeProps} from '../utils';
39+
import {useDevWarning} from '../hooks/useDevWarning';
3840
import {EDGE_COMP_ATTR} from '../Layout/edgeCompensation.stylex';
3941
import {themeProps} from '../utils/themeProps';
4042
import {focusOutlineProps} from '../utils/focusOutline.stylex';
@@ -64,8 +66,22 @@ export interface TabProps extends BaseProps<HTMLButtonElement> {
6466
isLabelHidden?: boolean;
6567
/**
6668
* URL to navigate to. When provided, renders as an anchor element.
69+
*
70+
* A tab that navigates keeps the strip on the navigation pattern. Ignored
71+
* only in a TabList given an explicit `role="tablist"`: activating a tab
72+
* there swaps a panel in place, so a tab that navigates would be a false
73+
* statement.
6774
*/
6875
href?: string;
76+
/**
77+
* Id of the panel this tab controls, wired up as `aria-controls` where the
78+
* TabList speaks the tabs pattern. Put the same id on the panel element.
79+
*
80+
* Has no effect under the navigation pattern, where there is no panel to
81+
* associate — including where the strip picked that pattern because it
82+
* holds something that is not a tab.
83+
*/
84+
panelId?: string;
6985
/**
7086
* Icon element shown when tab is not selected.
7187
*/
@@ -228,6 +244,7 @@ export function Tab({
228244
label,
229245
isLabelHidden = false,
230246
href,
247+
panelId,
231248
icon,
232249
selectedIcon,
233250
endContent,
@@ -242,13 +259,43 @@ export function Tab({
242259
const isSelected = tabListCtx.value === value;
243260
const size: TabListSize = tabListCtx.size;
244261
const isFill = tabListCtx.layout === 'fill';
262+
const isTabsPattern = tabListCtx.pattern === 'tabs';
263+
// An href gives way to the tabs pattern only where the consumer asked for
264+
// that pattern. Where the strip picked it, the link stays a link: the strip
265+
// reads its own children back and settles on the navigation pattern, so a
266+
// tab that navigates never ends up inside a tablist.
267+
const isLink = href != null && (!isTabsPattern || tabListCtx.isPatternAuto);
268+
const isTabRole = isTabsPattern && !isLink;
245269
const displayIcon = isSelected && selectedIcon ? selectedIcon : icon;
246270
const hasVisibleLabel = !isLabelHidden && label !== '';
247271

248272
const handleSelect = useCallback(() => {
249273
tabListCtx.onChange(value);
250274
}, [tabListCtx, value]);
251275

276+
useDevWarning(
277+
'Tab',
278+
'href is ignored in a role="tablist" TabList — a tab swaps a panel in ' +
279+
'place rather than navigating. Drop the href, or drop the role and let ' +
280+
'the strip pick its own pattern.',
281+
isTabRole && href != null,
282+
);
283+
284+
// A consumer who wired aria-controls by hand already said which panel this
285+
// is, so panelId is the sugar, not the only way in.
286+
const controls = panelId ?? restProps['aria-controls'];
287+
288+
// Only where the tabs pattern was asked for: a strip left to pick reaches it
289+
// through tabs that were written for the navigation pattern, and scolding
290+
// the consumer for a panel they were never asked for is noise.
291+
useDevWarning(
292+
'Tab',
293+
'a tab in a role="tablist" TabList controls nothing: pass panelId with ' +
294+
'the id of the panel it opens, so assistive technology can associate ' +
295+
'the two.',
296+
isTabRole && !tabListCtx.isPatternAuto && controls == null,
297+
);
298+
252299
const iconElement = displayIcon ? (
253300
<span {...stylex.props(styles.icon, iconSizeStyles[size])}>
254301
{displayIcon}
@@ -260,11 +307,25 @@ export function Tab({
260307
...(isLabelHidden ? {'aria-label': label} : {}),
261308
[EDGE_COMP_ATTR]: '',
262309
'data-tab-value': value,
263-
// Generic `true` ("the current item within a set"), not `page`: the strip
264-
// switches views in place at least as often as it navigates, and claiming
265-
// "current page" when no page changed is a false statement to a screen
266-
// reader. Stays truthful for the `href` case too, just less specific.
267-
'aria-current': isSelected ? ('true' as const) : undefined,
310+
...(isTabRole
311+
? {
312+
role: 'tab' as const,
313+
'aria-selected': isSelected,
314+
// Only when there is a panel to point at: an aria-controls whose
315+
// target does not exist is an invalid attribute value, which is a
316+
// worse state than saying nothing. The dev warning above asks for
317+
// the id instead.
318+
'aria-controls': controls,
319+
}
320+
: {
321+
// Generic `true` ("the current item within a set"), not `page`: the
322+
// strip switches views in place at least as often as it navigates,
323+
// and claiming "current page" when no page changed is a false
324+
// statement to a screen reader. Stays truthful for the `href` case
325+
// too, just less specific. A tab role states this with
326+
// aria-selected instead.
327+
'aria-current': isSelected ? ('true' as const) : undefined,
328+
}),
268329
// Roving tabindex: the tab strip is a single Tab stop. The selected tab is
269330
// the tabbable one; the rest are reachable via arrow keys (handled by
270331
// TabList's onKeyDown). When no tab is selected, TabList's repair effect
@@ -321,7 +382,7 @@ export function Tab({
321382
<span {...stylex.props(styles.endContentWrapper)}>{endContent}</span>
322383
) : null;
323384

324-
if (href != null) {
385+
if (isLink) {
325386
return (
326387
<LinkComponent
327388
ref={ref}

0 commit comments

Comments
 (0)