Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .changeset/tablist-aria-role.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@astryxdesign/core': patch
---

[feat] TabList: a strip that switches panels in place can now say so with `role="tablist"`, and it 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 the switch: `TabList` declares `role?: AriaRole` and reads it, the way `LayoutHeader`, `LayoutContent` and `LayoutPanel` already declare and document theirs. The keyboard behaviour the pattern asks for was already there: arrows move between tabs, Tab leaves the strip. Under the asserted role the strip takes only the horizontal arrows, leaving ArrowUp and ArrowDown to scroll the page.

`role` already reached the DOM through `{...restProps}`, so a caller could pass `role="tablist"` and get a tablist whose children were still `<button>`s with `aria-current` — invalid markup, no `aria-selected`, and no warning. Reading the role turns that silent breakage into the correct behaviour; declaring it is what puts it in the type, the prop table and the docs.

**Nothing changes for a caller who passes no `role`**: the strip is the `<nav>` landmark with `aria-current` it has always been. Any other role still passes through to the element untouched.

Two development warnings come with the asserted role, and only with it. A tab with an `href` is a false statement inside a tablist, so the `href` is ignored and the warning says so. And a tab that controls nothing gets asked for a `panelId` — either that 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. A menu or any other non-tab in a tablist strip is invalid markup, and warns too. The mirror case warns as well: a `panelId` on a strip that is not a tablist has no panel relationship to state, and is dropped.

@cixzhang
58 changes: 58 additions & 0 deletions apps/storybook/stories/TabList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,61 @@ export const OverflowVisible: Story = {
);
},
};

/**
* `role="tablist"` asks for the WAI-ARIA tabs pattern: `role="tablist"` /
* `role="tab"`, `aria-selected`, and each tab pointing at the panel it
* controls. A screen reader announces "tab 2 of 3, selected" and can move to
* the panel it opens. Without it the strip stays a `<nav>` landmark marking
* the current tab with `aria-current`.
*/
export const TabsPattern: Story = {
render: () => {
const [value, setValue] = useState('overview');
const panels = {
overview: 'Everything at a glance.',
activity: 'What happened recently.',
members: 'Who has access.',
};
return (
<div style={{display: 'grid', gap: '12px', maxWidth: '400px'}}>
<TabList
value={value}
onChange={setValue}
role="tablist"
aria-label="Project views"
hasDivider>
<Tab
value="overview"
label="Overview"
id="tab-overview"
panelId="panel-overview"
/>
<Tab
value="activity"
label="Activity"
id="tab-activity"
panelId="panel-activity"
/>
<Tab
value="members"
label="Members"
id="tab-members"
panelId="panel-members"
/>
</TabList>
{Object.entries(panels).map(([key, text]) => (
<div
key={key}
id={`panel-${key}`}
role="tabpanel"
aria-labelledby={`tab-${key}`}
tabIndex={0}
hidden={key !== value}>
{text}
</div>
))}
</div>
);
},
};
14 changes: 13 additions & 1 deletion packages/core/src/TabList/Tab.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ export const docs = {
name: 'href',
type: 'string',
description:
'URL to navigate to; when provided, the tab renders as an anchor element.',
'URL to navigate to; when provided, the tab renders as an anchor element. Ignored in a TabList given an explicit role="tablist".',
},
{
name: 'panelId',
type: 'string',
description:
'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, and a development warning says so.',
},
{
name: 'as',
Expand Down Expand Up @@ -138,6 +144,12 @@ export const docsZh = {
type: 'string',
description: '要导航到的 URL;提供时,标签渲染为锚点元素。',
},
{
name: 'panelId',
type: 'string',
description:
'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, and a development warning says so.',
},
{
name: 'as',
type: 'LinkComponentType',
Expand Down
74 changes: 67 additions & 7 deletions packages/core/src/TabList/Tab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@
* @input Uses React, StyleX, TabListContext
* @output Exports Tab component and TabProps type
* @position Core tab item; renders as button or anchor in navigation with a
* divider-overlay selected indicator
* divider-overlay selected indicator. Where the TabList speaks the tabs
* pattern it is a button with role="tab".
*
* SYNC: When modified, update:
* - /packages/core/src/TabList/TabList.doc.mjs
Expand Down Expand Up @@ -35,6 +36,7 @@ import {tabScope} from './tab.markers.stylex';
import {useLinkComponent} from '../Link/useLinkComponent';
import type {LinkComponentType} from '../Link/types';
import {mergeProps} from '../utils';
import {useDevWarning} from '../hooks/useDevWarning';
import {EDGE_COMP_ATTR} from '../Layout/edgeCompensation.stylex';
import {themeProps} from '../utils/themeProps';
import {focusOutlineProps} from '../utils/focusOutline.stylex';
Expand Down Expand Up @@ -64,8 +66,20 @@ export interface TabProps extends BaseProps<HTMLButtonElement> {
isLabelHidden?: boolean;
/**
* URL to navigate to. When provided, renders as an anchor element.
*
* Ignored in a TabList given an explicit `role="tablist"`: activating a tab
* there swaps a panel in place, so a tab that navigates would be a false
* statement.
*/
href?: string;
/**
* 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.
*
* Has no effect under the navigation pattern, where there is no panel to
* associate — a development warning says so.
*/
panelId?: string;
/**
* Icon element shown when tab is not selected.
*/
Expand Down Expand Up @@ -228,6 +242,7 @@ export function Tab({
label,
isLabelHidden = false,
href,
panelId,
icon,
selectedIcon,
endContent,
Expand All @@ -242,13 +257,44 @@ export function Tab({
const isSelected = tabListCtx.value === value;
const size: TabListSize = tabListCtx.size;
const isFill = tabListCtx.layout === 'fill';
const isTabsPattern = tabListCtx.pattern === 'tabs';
const isLink = href != null && !isTabsPattern;
const isTabRole = isTabsPattern && !isLink;
const displayIcon = isSelected && selectedIcon ? selectedIcon : icon;
const hasVisibleLabel = !isLabelHidden && label !== '';

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

useDevWarning(
'Tab',
'href is ignored in a role="tablist" TabList — a tab swaps a panel in ' +
'place rather than navigating. Drop the href, or drop the role for the ' +
'navigation pattern.',
isTabRole && href != null,
);

// A consumer who wired aria-controls by hand already said which panel this
// is, so panelId is the sugar, not the only way in.
const controls = panelId ?? restProps['aria-controls'];

useDevWarning(
'Tab',
'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.

);

useDevWarning(
'Tab',
'panelId does nothing outside a role="tablist" TabList — the navigation ' +
'pattern has no panel to associate. Give the TabList role="tablist", ' +
'or drop the panelId.',
!isTabsPattern && panelId != null,
);

const iconElement = displayIcon ? (
<span {...stylex.props(styles.icon, iconSizeStyles[size])}>
{displayIcon}
Expand All @@ -260,11 +306,25 @@ export function Tab({
...(isLabelHidden ? {'aria-label': label} : {}),
[EDGE_COMP_ATTR]: '',
'data-tab-value': value,
// Generic `true` ("the current item within a set"), not `page`: the strip
// switches views in place at least as often as it navigates, and claiming
// "current page" when no page changed is a false statement to a screen
// reader. Stays truthful for the `href` case too, just less specific.
'aria-current': isSelected ? ('true' as const) : undefined,
...(isTabRole
? {
role: 'tab' as const,
'aria-selected': isSelected,
// Only when there is a panel to point at: an aria-controls whose
// target does not exist is an invalid attribute value, which is a
// worse state than saying nothing. The dev warning above asks for
// the id instead.
'aria-controls': controls,
}
: {
// Generic `true` ("the current item within a set"), not `page`: the
// strip switches views in place at least as often as it navigates,
// and claiming "current page" when no page changed is a false
// statement to a screen reader. Stays truthful for the `href` case
// too, just less specific. A tab role states this with
// aria-selected instead.
'aria-current': isSelected ? ('true' as const) : undefined,
}),
// Roving tabindex: the tab strip is a single Tab stop. The selected tab is
// the tabbable one; the rest are reachable via arrow keys (handled by
// TabList's onKeyDown). When no tab is selected, TabList's repair effect
Expand Down Expand Up @@ -321,7 +381,7 @@ export function Tab({
<span {...stylex.props(styles.endContentWrapper)}>{endContent}</span>
) : null;

if (href != null) {
if (isLink) {
return (
<LinkComponent
ref={ref}
Expand Down
14 changes: 11 additions & 3 deletions packages/core/src/TabList/TabList.doc.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export const docs = {
{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},
],
},
description: 'Nav wrapper that provides TabListContext (value, onChange, size) to Tab and TabMenu children.',
description: 'Tab strip that provides TabListContext (value, onChange, size) to Tab and TabMenu children; a nav landmark, or the WAI-ARIA tabs pattern where role="tablist" asks for it.',
props: [
{
name: 'value',
Expand Down Expand Up @@ -60,6 +60,11 @@ export const docs = {
description: 'Whether to show a bottom border divider under the tab list.',
default: 'false',
},
{
name: 'role',
type: 'AriaRole',
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 is a nav landmark marking the current tab with aria-current. Any other value is passed through to the element unchanged.",
},
{
name: 'overflow',
type: "'auto' | 'scroll' | 'visible'",
Expand All @@ -69,7 +74,7 @@ export const docs = {
{
name: 'children',
type: 'ReactNode',
description: 'Tab and TabMenu items to render inside the nav.',
description: 'Tab and TabMenu items to render inside the strip.',
slotElements: [
{
__element: 'Tab',
Expand Down Expand Up @@ -98,6 +103,7 @@ export const docs = {
{ guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
{ 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.' },
{ 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.' },
{ guidance: true, description: 'Reach for role="tablist" when the strip switches panels in place, and give each tab a panelId pointing at the panel it opens: that link is how a screen reader gets from a tab to its content. Leave it off for navigation between views.' },
{ guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
{ guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
{ 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.' },
Expand All @@ -119,6 +125,7 @@ export const docsZh = {
{ guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
{ 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.' },
{ 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.' },
{ guidance: true, description: 'Reach for role="tablist" when the strip switches panels in place, and give each tab a panelId pointing at the panel it opens: that link is how a screen reader gets from a tab to its content. Leave it off for navigation between views.' },
{ guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
{ guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
{ 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.' },
Expand All @@ -133,14 +140,15 @@ export const docsZh = {

/** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
export const docsDense = {
description: 'Tab navigation w/ overflow menu support; semantic nav landmark w/ button or anchor tab items.',
description: 'Tab strip w/ overflow scrolling; nav landmark by default, WAI-ARIA tabs pattern under role="tablist".',
usage: {
description:
'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.',
bestPractices: [
{ guidance: true, description: 'Keep tab labels short and descriptive so users can quickly scan available sections.' },
{ 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.' },
{ 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.' },
{ guidance: true, description: 'Reach for role="tablist" when the strip switches panels in place, and give each tab a panelId pointing at the panel it opens: that link is how a screen reader gets from a tab to its content. Leave it off for navigation between views.' },
{ guidance: false, description: 'Use tabs for sequential steps or workflows; use a stepper or wizard pattern instead.' },
{ guidance: false, description: 'Place more than 6–8 visible tabs before the overflow menu; prioritize the most important categories.' },
{ 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.' },
Expand Down
Loading
Loading