You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
[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.
Copy file name to clipboardExpand all lines: packages/core/src/TabList/Tab.doc.mjs
+13-1Lines changed: 13 additions & 1 deletion
Original file line number
Diff line number
Diff line change
@@ -40,7 +40,13 @@ export const docs = {
40
40
name: 'href',
41
41
type: 'string',
42
42
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.',
44
50
},
45
51
{
46
52
name: 'as',
@@ -138,6 +144,12 @@ export const docsZh = {
138
144
type: 'string',
139
145
description: '要导航到的 URL;提供时,标签渲染为锚点元素。',
140
146
},
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.',
Copy file name to clipboardExpand all lines: packages/core/src/TabList/TabList.doc.mjs
+11-3Lines changed: 11 additions & 3 deletions
Original file line number
Diff line number
Diff line change
@@ -28,7 +28,7 @@ export const docs = {
28
28
{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},
29
29
],
30
30
},
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.',
32
32
props: [
33
33
{
34
34
name: 'value',
@@ -60,6 +60,11 @@ export const docs = {
60
60
description: 'Whether to show a bottom border divider under the tab list.',
61
61
default: 'false',
62
62
},
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
+
},
63
68
{
64
69
name: 'overflow',
65
70
type: "'auto' | 'scroll' | 'none'",
@@ -69,7 +74,7 @@ export const docs = {
69
74
{
70
75
name: 'children',
71
76
type: 'ReactNode',
72
-
description: 'Tab and TabMenu items to render inside the nav.',
77
+
description: 'Tab and TabMenu items to render inside the strip.',
73
78
slotElements: [
74
79
{
75
80
__element: 'Tab',
@@ -98,6 +103,7 @@ export const docs = {
98
103
{guidance: true,description: 'Keep tab labels short and descriptive so users can quickly scan available sections.'},
99
104
{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.'},
100
105
{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.'},
101
107
{guidance: false,description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.'},
102
108
{guidance: false,description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.'},
103
109
{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 = {
119
125
{guidance: true,description: 'Keep tab labels short and descriptive so users can quickly scan available sections.'},
120
126
{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.'},
121
127
{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.'},
122
129
{guidance: false,description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.'},
123
130
{guidance: false,description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.'},
124
131
{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.'},
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.',
137
144
usage: {
138
145
description:
139
146
'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.',
140
147
bestPractices: [
141
148
{guidance: true,description: 'Keep tab labels short and descriptive so users can quickly scan available sections.'},
142
149
{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.'},
143
150
{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.'},
144
152
{guidance: false,description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.'},
145
153
{guidance: false,description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.'},
146
154
{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