Skip to content

Commit 8f95ae4

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 8f95ae4

9 files changed

Lines changed: 726 additions & 69 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': patch
3+
---
4+
5+
[feat] 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

apps/storybook/stories/TabList.stories.tsx

Lines changed: 89 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',
@@ -404,3 +434,62 @@ export const OverflowNone: Story = {
404434
);
405435
},
406436
};
437+
438+
/**
439+
* `role="tablist"` asks for the WAI-ARIA tabs pattern: `role="tablist"` /
440+
* `role="tab"`, `aria-selected`, and each tab pointing at the panel it
441+
* controls. A screen reader announces "tab 2 of 3, selected" and can move to
442+
* the panel it opens. A strip of nothing but tabs reaches the same pattern on
443+
* its own — the explicit role is how you keep it when the strip also holds
444+
* something else, and how you ask to be warned when it does.
445+
*/
446+
export const TabsPattern: Story = {
447+
render: () => {
448+
const [value, setValue] = useState('overview');
449+
const panels = {
450+
overview: 'Everything at a glance.',
451+
activity: 'What happened recently.',
452+
members: 'Who has access.',
453+
};
454+
return (
455+
<div style={{display: 'grid', gap: '12px', maxWidth: '400px'}}>
456+
<TabList
457+
value={value}
458+
onChange={setValue}
459+
role="tablist"
460+
aria-label="Project views"
461+
hasDivider>
462+
<Tab
463+
value="overview"
464+
label="Overview"
465+
id="tab-overview"
466+
panelId="panel-overview"
467+
/>
468+
<Tab
469+
value="activity"
470+
label="Activity"
471+
id="tab-activity"
472+
panelId="panel-activity"
473+
/>
474+
<Tab
475+
value="members"
476+
label="Members"
477+
id="tab-members"
478+
panelId="panel-members"
479+
/>
480+
</TabList>
481+
{Object.entries(panels).map(([key, text]) => (
482+
<div
483+
key={key}
484+
id={`panel-${key}`}
485+
role="tabpanel"
486+
aria-labelledby={`tab-${key}`}
487+
tabIndex={0}
488+
hidden={key !== value}>
489+
{text}
490+
</div>
491+
))}
492+
</div>
493+
);
494+
},
495+
};

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.',
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.',
152+
},
141153
{
142154
name: 'as',
143155
type: 'LinkComponentType',

packages/core/src/TabList/Tab.tsx

Lines changed: 67 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,21 @@ 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.
82+
*/
83+
panelId?: string;
6984
/**
7085
* Icon element shown when tab is not selected.
7186
*/
@@ -228,6 +243,7 @@ export function Tab({
228243
label,
229244
isLabelHidden = false,
230245
href,
246+
panelId,
231247
icon,
232248
selectedIcon,
233249
endContent,
@@ -242,13 +258,43 @@ export function Tab({
242258
const isSelected = tabListCtx.value === value;
243259
const size: TabListSize = tabListCtx.size;
244260
const isFill = tabListCtx.layout === 'fill';
261+
const isTabsPattern = tabListCtx.pattern === 'tabs';
262+
// An href gives way to the tabs pattern only where the consumer asked for
263+
// that pattern. Where the strip picked it, the link stays a link: the strip
264+
// reads its own children back and settles on the navigation pattern, so a
265+
// tab that navigates never ends up inside a tablist.
266+
const isLink = href != null && (!isTabsPattern || tabListCtx.isPatternAuto);
267+
const isTabRole = isTabsPattern && !isLink;
245268
const displayIcon = isSelected && selectedIcon ? selectedIcon : icon;
246269
const hasVisibleLabel = !isLabelHidden && label !== '';
247270

248271
const handleSelect = useCallback(() => {
249272
tabListCtx.onChange(value);
250273
}, [tabListCtx, value]);
251274

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

324-
if (href != null) {
384+
if (isLink) {
325385
return (
326386
<LinkComponent
327387
ref={ref}

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

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const docs = {
2828
{name: '--_tab-indicator-bottom', description: 'Vertical offset of the selected-tab indicator from the tab bottom edge. A host that draws its own bottom divider (Toolbar) sets this so the indicator sits on the divider instead of above it.', default: '-1px', private: true},
2929
],
3030
},
31-
description: 'Nav wrapper that provides TabListContext (value, onChange, size) to Tab and TabMenu children.',
31+
description: 'Tab strip that provides TabListContext (value, onChange, size) to Tab and TabMenu children, speaking either the WAI-ARIA tabs pattern or the navigation one.',
3232
props: [
3333
{
3434
name: 'value',
@@ -60,6 +60,11 @@ export const docs = {
6060
description: 'Whether to show a bottom border divider under the tab list.',
6161
default: 'false',
6262
},
63+
{
64+
name: 'role',
65+
type: 'AriaRole',
66+
description: "ARIA role for the strip. 'tablist' asks for the WAI-ARIA tabs pattern: role=\"tablist\" / role=\"tab\" and aria-selected, with each tab pointing at the panel it controls via its panelId; only tabs may live in a tablist strip, and an href on a tab is ignored there. Left unset, the strip picks the pattern from what it renders: the tabs pattern when it holds nothing but tabs, and a nav landmark with aria-current when it holds anything else — a menu, a link, a control of your own. Pass 'navigation' to keep the landmark whatever the strip contains. Any other value is passed through to the element unchanged.",
67+
},
6368
{
6469
name: 'overflow',
6570
type: "'auto' | 'scroll' | 'none'",
@@ -69,7 +74,7 @@ export const docs = {
6974
{
7075
name: 'children',
7176
type: 'ReactNode',
72-
description: 'Tab and TabMenu items to render inside the nav.',
77+
description: 'Tab and TabMenu items to render inside the strip.',
7378
slotElements: [
7479
{
7580
__element: 'Tab',
@@ -98,6 +103,7 @@ export const docs = {
98103
{ guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
99104
{ guidance: true, description: 'Leave overflow handling on: a strip narrower than its tabs scrolls, and the selected tab is kept in view. Use TabMenu when you want a curated group of extra options rather than a scrolling strip.' },
100105
{ guidance: true, description: 'When using hasDivider with action buttons alongside tabs, match the Button size to the TabList size (both md, both sm); the divided tab strip reserves space so tabs and same-size buttons align to a shared baseline above the rail.' },
106+
{ guidance: true, description: 'Give each panel an id and point its tab at it with panelId: a strip of plain tabs is announced as a tablist, and that link is how a screen reader gets from a tab to the content it opens.' },
101107
{ guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
102108
{ guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
103109
{ guidance: false, description: 'Confuse TabList with SegmentedControl or ToggleButton. TabList is for navigation between views. SegmentedControl and ToggleButton are input controls: SegmentedControl always has exactly one selected option, while ToggleButton can be toggled on or off.' },
@@ -119,6 +125,7 @@ export const docsZh = {
119125
{ guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
120126
{ guidance: true, description: 'Leave overflow handling on: a strip narrower than its tabs scrolls, and the selected tab is kept in view. Use TabMenu when you want a curated group of extra options rather than a scrolling strip.' },
121127
{ guidance: true, description: 'When using hasDivider with action buttons alongside tabs, match the Button size to the TabList size (both md, both sm); the divided tab strip reserves space so tabs and same-size buttons align to a shared baseline above the rail.' },
128+
{ guidance: true, description: 'Give each panel an id and point its tab at it with panelId: a strip of plain tabs is announced as a tablist, and that link is how a screen reader gets from a tab to the content it opens.' },
122129
{ guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
123130
{ guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
124131
{ guidance: false, description: 'Confuse TabList with SegmentedControl or ToggleButton. TabList is for navigation between views. SegmentedControl and ToggleButton are input controls: SegmentedControl always has exactly one selected option, while ToggleButton can be toggled on or off.' },
@@ -133,14 +140,15 @@ export const docsZh = {
133140

134141
/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
135142
export const docsDense = {
136-
description: 'Tab navigation w/ overflow menu support; semantic nav landmark w/ button or anchor tab items.',
143+
description: 'Tab strip w/ overflow scrolling; ARIA tabs pattern by default, nav landmark w/ anchor or menu items.',
137144
usage: {
138145
description:
139146
'TabList provides tab-style navigation for organizing content into categorized sections. Use it to let users switch between related views without leaving the page, with overflow items handled by a built-in "more" menu.',
140147
bestPractices: [
141148
{ guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
142149
{ guidance: true, description: 'Leave overflow handling on: a strip narrower than its tabs scrolls, and the selected tab is kept in view. Use TabMenu when you want a curated group of extra options rather than a scrolling strip.' },
143150
{ guidance: true, description: 'When using hasDivider with action buttons alongside tabs, match the Button size to the TabList size (both md, both sm); the divided tab strip reserves space so tabs and same-size buttons align to a shared baseline above the rail.' },
151+
{ guidance: true, description: 'Give each panel an id and point its tab at it with panelId: a strip of plain tabs is announced as a tablist, and that link is how a screen reader gets from a tab to the content it opens.' },
144152
{ guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
145153
{ guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
146154
{ guidance: false, description: 'Confuse TabList with SegmentedControl or ToggleButton. TabList is for navigation between views. SegmentedControl and ToggleButton are input controls: SegmentedControl always has exactly one selected option, while ToggleButton can be toggled on or off.' },

0 commit comments

Comments
 (0)