Skip to content

Commit 8f976cd

Browse files
committed
feat(core): TabList can speak 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 the strip to get role=tablist, aria-selected and aria-controls. mode="tablist" gives them the tabs pattern; nav stays the default and is untouched. A tab does not navigate, so href is ignored there, and a tablist owns only tabs, so anything else in the strip warns in dev — detected from the rendered DOM rather than from children, which misses a conditional, a map, or a consumer's own wrapper.
1 parent afe68ae commit 8f976cd

9 files changed

Lines changed: 387 additions & 22 deletions

File tree

.changeset/tablist-mode.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@astryxdesign/core': patch
3+
---
4+
5+
[feat] TabList: a new `mode` prop chooses which ARIA pattern the strip speaks. `nav` stays the default and is unchanged — a navigation landmark whose current item is marked with `aria-current`. `mode="tablist"` implements the WAI-ARIA tabs pattern instead: `role="tablist"` on the strip, `role="tab"` and `aria-selected` on the tabs, and `aria-controls` pointing at the panel each tab opens, taken from a new `panelId` prop on `Tab`. The keyboard behaviour the pattern asks for was already there — arrows move between tabs, Tab leaves the strip. Because a tab swaps a panel in place rather than navigating, an `href` is ignored in this mode and the tab stays a button, and because a tablist owns only tabs, anything else rendered inside the strip produces a development warning naming what it found. Both warnings are development-only.
6+
7+
@cixzhang

apps/storybook/stories/TabList.stories.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -404,3 +404,61 @@ export const OverflowNone: Story = {
404404
);
405405
},
406406
};
407+
408+
/**
409+
* `mode="tablist"` speaks the WAI-ARIA tabs pattern instead of the navigation
410+
* one: `role="tablist"` / `role="tab"`, `aria-selected`, and each tab pointing
411+
* at the panel it controls. Use it when the strip has real panels below it —
412+
* a screen reader then announces "tab 2 of 3, selected" and can move to the
413+
* panel it opens. `nav` remains the default.
414+
*/
415+
export const TabsPattern: Story = {
416+
render: () => {
417+
const [value, setValue] = useState('overview');
418+
const panels = {
419+
overview: 'Everything at a glance.',
420+
activity: 'What happened recently.',
421+
members: 'Who has access.',
422+
};
423+
return (
424+
<div style={{display: 'grid', gap: '12px', maxWidth: '400px'}}>
425+
<TabList
426+
value={value}
427+
onChange={setValue}
428+
mode="tablist"
429+
aria-label="Project views"
430+
hasDivider>
431+
<Tab
432+
value="overview"
433+
label="Overview"
434+
id="tab-overview"
435+
panelId="panel-overview"
436+
/>
437+
<Tab
438+
value="activity"
439+
label="Activity"
440+
id="tab-activity"
441+
panelId="panel-activity"
442+
/>
443+
<Tab
444+
value="members"
445+
label="Members"
446+
id="tab-members"
447+
panelId="panel-members"
448+
/>
449+
</TabList>
450+
{Object.entries(panels).map(([key, text]) => (
451+
<div
452+
key={key}
453+
id={`panel-${key}`}
454+
role="tabpanel"
455+
aria-labelledby={`tab-${key}`}
456+
tabIndex={0}
457+
hidden={key !== value}>
458+
{text}
459+
</div>
460+
))}
461+
</div>
462+
);
463+
},
464+
};

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export const docs = {
4242
description:
4343
'URL to navigate to; when provided, the tab renders as an anchor element.',
4444
},
45+
{
46+
name: 'panelId',
47+
type: 'string',
48+
description:
49+
'Id of the panel this tab controls, wired up as aria-controls in a tablist-mode TabList. Put the same id on the panel element. No effect in nav mode.',
50+
},
4551
{
4652
name: 'as',
4753
type: 'LinkComponentType',
@@ -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 in a tablist-mode TabList. Put the same id on the panel element. No effect in nav mode.',
152+
},
141153
{
142154
name: 'as',
143155
type: 'LinkComponentType',

packages/core/src/TabList/Tab.tsx

Lines changed: 54 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. In a tablist-mode TabList it is
11+
* always 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,18 @@ export interface TabProps extends BaseProps<HTMLButtonElement> {
6466
isLabelHidden?: boolean;
6567
/**
6668
* URL to navigate to. When provided, renders as an anchor element.
69+
*
70+
* Ignored in a `tablist`-mode TabList: activating a tab swaps a panel in
71+
* place, so a tab that navigates would be a false statement.
6772
*/
6873
href?: string;
74+
/**
75+
* Id of the panel this tab controls, wired up as `aria-controls` in a
76+
* `tablist`-mode TabList. Put the same id on the panel element.
77+
*
78+
* Has no effect in `nav` mode, where there is no panel to associate.
79+
*/
80+
panelId?: string;
6981
/**
7082
* Icon element shown when tab is not selected.
7183
*/
@@ -228,6 +240,7 @@ export function Tab({
228240
label,
229241
isLabelHidden = false,
230242
href,
243+
panelId,
231244
icon,
232245
selectedIcon,
233246
endContent,
@@ -242,13 +255,33 @@ export function Tab({
242255
const isSelected = tabListCtx.value === value;
243256
const size: TabListSize = tabListCtx.size;
244257
const isFill = tabListCtx.layout === 'fill';
258+
const isTabRole = tabListCtx.mode === 'tablist';
245259
const displayIcon = isSelected && selectedIcon ? selectedIcon : icon;
246260
const hasVisibleLabel = !isLabelHidden && label !== '';
247261

248262
const handleSelect = useCallback(() => {
249263
tabListCtx.onChange(value);
250264
}, [tabListCtx, value]);
251265

266+
useDevWarning(
267+
'Tab',
268+
'href is ignored in a tablist-mode TabList — a tab swaps a panel in ' +
269+
'place rather than navigating. Drop the href, or use mode="nav".',
270+
isTabRole && href != null,
271+
);
272+
273+
// A consumer who wired aria-controls by hand already said which panel this
274+
// is, so panelId is the sugar, not the only way in.
275+
const controls = panelId ?? restProps['aria-controls'];
276+
277+
useDevWarning(
278+
'Tab',
279+
'a tab in a tablist-mode TabList controls nothing: pass panelId with ' +
280+
'the id of the panel it opens, so assistive technology can associate ' +
281+
'the two.',
282+
isTabRole && controls == null,
283+
);
284+
252285
const iconElement = displayIcon ? (
253286
<span {...stylex.props(styles.icon, iconSizeStyles[size])}>
254287
{displayIcon}
@@ -260,11 +293,25 @@ export function Tab({
260293
...(isLabelHidden ? {'aria-label': label} : {}),
261294
[EDGE_COMP_ATTR]: '',
262295
'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,
296+
...(isTabRole
297+
? {
298+
role: 'tab' as const,
299+
'aria-selected': isSelected,
300+
// Only when there is a panel to point at: an aria-controls whose
301+
// target does not exist is an invalid attribute value, which is a
302+
// worse state than saying nothing. The dev warning above asks for
303+
// the id instead.
304+
'aria-controls': controls,
305+
}
306+
: {
307+
// Generic `true` ("the current item within a set"), not `page`: the
308+
// strip switches views in place at least as often as it navigates,
309+
// and claiming "current page" when no page changed is a false
310+
// statement to a screen reader. Stays truthful for the `href` case
311+
// too, just less specific. A tab role states this with
312+
// aria-selected instead.
313+
'aria-current': isSelected ? ('true' as const) : undefined,
314+
}),
268315
// Roving tabindex: the tab strip is a single Tab stop. The selected tab is
269316
// the tabbable one; the rest are reachable via arrow keys (handled by
270317
// TabList's onKeyDown). When no tab is selected, TabList's repair effect
@@ -321,7 +368,7 @@ export function Tab({
321368
<span {...stylex.props(styles.endContentWrapper)}>{endContent}</span>
322369
) : null;
323370

324-
if (href != null) {
371+
if (href != null && !isTabRole) {
325372
return (
326373
<LinkComponent
327374
ref={ref}

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,12 @@ export const docs = {
6060
description: 'Whether to show a bottom border divider under the tab list.',
6161
default: 'false',
6262
},
63+
{
64+
name: 'mode',
65+
type: "'nav' | 'tablist'",
66+
description: "Which ARIA pattern the strip implements. 'nav' (the default) is a navigation landmark whose current item is marked with aria-current. 'tablist' is 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.",
67+
default: "'nav'",
68+
},
6369
{
6470
name: 'overflow',
6571
type: "'auto' | 'scroll' | 'none'",

packages/core/src/TabList/TabList.test.tsx

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,3 +1187,166 @@ describe('TabList overflow (scroll)', () => {
11871187
expect(scrollBy).not.toHaveBeenCalled();
11881188
});
11891189
});
1190+
1191+
describe('TabList mode="tablist"', () => {
1192+
function warnSpy() {
1193+
return vi.spyOn(console, 'warn').mockImplementation(() => {});
1194+
}
1195+
1196+
it('speaks the tabs pattern: a labelled tablist of tabs with aria-selected', () => {
1197+
render(
1198+
<TabList value="b" onChange={() => {}} mode="tablist" aria-label="Views">
1199+
<Tab value="a" label="Alpha" panelId="panel-a" />
1200+
<Tab value="b" label="Beta" panelId="panel-b" />
1201+
</TabList>,
1202+
);
1203+
1204+
const tablist = screen.getByRole('tablist', {name: 'Views'});
1205+
const tabs = screen.getAllByRole('tab');
1206+
expect(tabs).toHaveLength(2);
1207+
expect(tabs.every(tab => tablist.contains(tab))).toBe(true);
1208+
expect(tabs[0]).toHaveAttribute('aria-selected', 'false');
1209+
expect(tabs[1]).toHaveAttribute('aria-selected', 'true');
1210+
expect(tabs[1]).toHaveAttribute('aria-controls', 'panel-b');
1211+
});
1212+
1213+
it('lets the consumer name the tablist from another element', () => {
1214+
render(
1215+
<>
1216+
<h2 id="views-heading">Project views</h2>
1217+
<TabList
1218+
value="a"
1219+
onChange={() => {}}
1220+
mode="tablist"
1221+
aria-labelledby="views-heading">
1222+
<Tab value="a" label="Alpha" panelId="panel-a" />
1223+
</TabList>
1224+
</>,
1225+
);
1226+
1227+
expect(screen.getByRole('tablist', {name: 'Project views'})).not.toBeNull();
1228+
});
1229+
1230+
it('is not a navigation landmark, and marks the selection with aria-selected rather than aria-current', () => {
1231+
const {container} = render(
1232+
<TabList value="a" onChange={() => {}} mode="tablist">
1233+
<Tab value="a" label="Alpha" panelId="panel-a" />
1234+
</TabList>,
1235+
);
1236+
1237+
expect(container.querySelector('nav')).toBeNull();
1238+
expect(screen.queryByRole('navigation')).toBeNull();
1239+
expect(screen.getByRole('tab')).not.toHaveAttribute('aria-current');
1240+
});
1241+
1242+
it('leaves nav mode exactly as it was', () => {
1243+
const {container} = render(
1244+
<TabList value="a" onChange={() => {}}>
1245+
<Tab value="a" label="Alpha" />
1246+
</TabList>,
1247+
);
1248+
1249+
expect(container.querySelector('nav')).not.toBeNull();
1250+
expect(screen.queryByRole('tablist')).toBeNull();
1251+
expect(screen.getByRole('button', {name: 'Alpha'})).toHaveAttribute(
1252+
'aria-current',
1253+
'true',
1254+
);
1255+
});
1256+
1257+
it('omits aria-controls when a tab has no panel, and says so once', () => {
1258+
const warn = warnSpy();
1259+
render(
1260+
<TabList value="a" onChange={() => {}} mode="tablist">
1261+
<Tab value="a" label="Alpha" />
1262+
</TabList>,
1263+
);
1264+
1265+
expect(screen.getByRole('tab')).not.toHaveAttribute('aria-controls');
1266+
expect(warn).toHaveBeenCalledWith(
1267+
expect.stringContaining(
1268+
'Tab: a tab in a tablist-mode TabList controls nothing',
1269+
),
1270+
);
1271+
warn.mockRestore();
1272+
});
1273+
1274+
it('leaves a hand-wired aria-controls alone, and does not ask for a panelId it already has', () => {
1275+
const warn = warnSpy();
1276+
render(
1277+
<TabList value="a" onChange={() => {}} mode="tablist">
1278+
<Tab value="a" label="Alpha" aria-controls="panel-written-by-hand" />
1279+
</TabList>,
1280+
);
1281+
1282+
expect(screen.getByRole('tab')).toHaveAttribute(
1283+
'aria-controls',
1284+
'panel-written-by-hand',
1285+
);
1286+
expect(warn).not.toHaveBeenCalled();
1287+
warn.mockRestore();
1288+
});
1289+
1290+
it('ignores href in tablist mode: the tab is a button, and the caller is told', () => {
1291+
const warn = warnSpy();
1292+
render(
1293+
<TabList value="a" onChange={() => {}} mode="tablist">
1294+
<Tab value="a" label="Alpha" panelId="panel-a" href="/alpha" />
1295+
</TabList>,
1296+
);
1297+
1298+
const tab = screen.getByRole('tab');
1299+
expect(tab.tagName).toBe('BUTTON');
1300+
expect(tab).not.toHaveAttribute('href');
1301+
expect(warn).toHaveBeenCalledWith(
1302+
expect.stringContaining('Tab: href is ignored in a tablist-mode TabList'),
1303+
);
1304+
warn.mockRestore();
1305+
});
1306+
1307+
it('still renders an anchor for an href in nav mode', () => {
1308+
render(
1309+
<TabList value="a" onChange={() => {}}>
1310+
<Tab value="a" label="Alpha" href="/alpha" />
1311+
</TabList>,
1312+
);
1313+
1314+
expect(screen.getByRole('link', {name: 'Alpha'})).toHaveAttribute(
1315+
'href',
1316+
'/alpha',
1317+
);
1318+
});
1319+
1320+
it('warns about anything in the strip that is not a tab, however it got there', () => {
1321+
const warn = warnSpy();
1322+
const showMenu = true;
1323+
render(
1324+
<TabList value="a" onChange={() => {}} mode="tablist">
1325+
<Tab value="a" label="Alpha" panelId="panel-a" />
1326+
{showMenu ? (
1327+
<div>
1328+
<TabMenu label="More" options={[{value: 'b', label: 'Beta'}]} />
1329+
</div>
1330+
) : null}
1331+
</TabList>,
1332+
);
1333+
1334+
expect(warn).toHaveBeenCalledWith(
1335+
expect.stringContaining('TabList: mode="tablist" owns only tabs'),
1336+
);
1337+
warn.mockRestore();
1338+
});
1339+
1340+
it('says nothing when the strip holds only tabs', () => {
1341+
const warn = warnSpy();
1342+
render(
1343+
<TabList value="a" onChange={() => {}} mode="tablist">
1344+
<Tab value="a" label="Alpha" panelId="panel-a" />
1345+
<Tab value="b" label="Beta" panelId="panel-b" />
1346+
</TabList>,
1347+
);
1348+
1349+
expect(warn).not.toHaveBeenCalled();
1350+
warn.mockRestore();
1351+
});
1352+
});

0 commit comments

Comments
 (0)