Skip to content

Commit cde5942

Browse files
ciampomirkajameskosteraduth
authored
@wordpress/ui: add Tabs (WordPress#74652)
* Initial commit * Fix imports react => @wordpress/elements import order * Add export * Adjust --wpds-color-stroke-interactive-neutral-strong * CHANGELOG * Fix stylelint errors * Add @wordpress/compose to the UI package * Unify tests, fix lint errors * Update package.json * remove cursor files (added by mistake) * Restore tooltip in stories * Move best practices file * Fix storybook mdx * Revert "Adjust --wpds-color-stroke-interactive-neutral-strong" This reverts commit 9ef48ac. * Fix docs * Remove custom focusable prop in favour of using tabIndex directly * Use Icon component instead of cloning an SVG * Fix renamed DS tokens * Use ComponentProps utility type * Support a second "State" argument in internal render prop type, like Base UI * Use underscore notation for Base UI imports * Fix unit tests * Simplify overflow measurement (remove unnecessary variables and checks) * remove stale CHANGELOG entry * Remove wrong package references * Revert "Support a second "State" argument in internal render prop type, like Base UI" This reverts commit 36bb104. * Do not forward state in the render prop * Update snippet * Fix changelog * Refactor from `density="compact"` to `variant="minimal"` * Move useState inside render function in docs code snippet * Follow Base UI renaming convention * Cleaner tabIndex fix in tests * Fix RTL edge fade when scrolling horizontally * Simplify tabIndex calculation for tablist * Do not use BEM * Simplify Storybook --- Co-authored-by: ciampo <mciampini@git.wordpress.org> Co-authored-by: mirka <0mirka00@git.wordpress.org> Co-authored-by: jameskoster <jameskoster@git.wordpress.org> Co-authored-by: aduth <aduth@git.wordpress.org>
1 parent 6f2c7aa commit cde5942

File tree

16 files changed

+3238
-9
lines changed

16 files changed

+3238
-9
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/ui/CHANGELOG.md

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,16 @@
22

33
## Unreleased
44

5+
### New Features
6+
7+
- Add `Tabs` primitive ([#74652](https://github.com/WordPress/gutenberg/pull/74652)).
8+
59
## 0.6.0 (2026-01-29)
610

11+
### New Features
12+
13+
- Add `Select` primitive ([#74661](https://github.com/WordPress/gutenberg/pull/74661)).
14+
715
## 0.5.0 (2026-01-16)
816

917
### Breaking Changes
@@ -20,4 +28,3 @@
2028
- Add `Button` component ([#74415](https://github.com/WordPress/gutenberg/pull/74415), [#74416](https://github.com/WordPress/gutenberg/pull/74416), [#74470](https://github.com/WordPress/gutenberg/pull/74470)).
2129
- Add `InputLayout` primitive ([#74313](https://github.com/WordPress/gutenberg/pull/74313)).
2230
- Add `Input` primitive ([#74615](https://github.com/WordPress/gutenberg/pull/74615)).
23-
- Add `Select` primitive ([#74661](https://github.com/WordPress/gutenberg/pull/74661)).

packages/ui/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
"dependencies": {
4646
"@base-ui/react": "^1.0.0",
4747
"@wordpress/a11y": "file:../a11y",
48+
"@wordpress/compose": "file:../compose",
4849
"@wordpress/element": "file:../element",
4950
"@wordpress/i18n": "file:../i18n",
5051
"@wordpress/icons": "file:../icons",

packages/ui/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@ export * from './form/primitives';
55
export * from './icon';
66
export * from './icon-button';
77
export * from './stack';
8+
export * as Tabs from './tabs';
89
export * as Tooltip from './tooltip';
910
export * from './visually-hidden';

packages/ui/src/tabs/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { List } from './list';
2+
import { Panel } from './panel';
3+
import { Root } from './root';
4+
import { Tab } from './tab';
5+
6+
export { Root, List, Panel, Tab };

packages/ui/src/tabs/list.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
import { forwardRef, useEffect, useState } from '@wordpress/element';
2+
import clsx from 'clsx';
3+
import { Tabs as _Tabs } from '@base-ui/react/tabs';
4+
import { useMergeRefs } from '@wordpress/compose';
5+
import styles from './style.module.css';
6+
import type { TabListProps } from './types';
7+
8+
// Account for sub-pixel rounding errors.
9+
const SCROLL_EPSILON = 1;
10+
11+
/**
12+
* Groups the individual tab buttons.
13+
*
14+
* `Tabs` is a collection of React components that combine to render
15+
* an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).
16+
*/
17+
export const List = forwardRef< HTMLDivElement, TabListProps >(
18+
function TabList(
19+
{
20+
children,
21+
variant = 'default',
22+
className,
23+
activateOnFocus,
24+
render,
25+
...otherProps
26+
},
27+
forwardedRef
28+
) {
29+
const [ listEl, setListEl ] = useState< HTMLDivElement | null >( null );
30+
const [ overflow, setOverflow ] = useState< {
31+
first: boolean;
32+
last: boolean;
33+
isScrolling: boolean;
34+
} >( {
35+
first: false,
36+
last: false,
37+
isScrolling: false,
38+
} );
39+
40+
// Check if list is overflowing when it scrolls or resizes.
41+
useEffect( () => {
42+
if ( ! listEl ) {
43+
return;
44+
}
45+
46+
const measureOverflow = () => {
47+
const { scrollWidth, clientWidth, scrollLeft } = listEl;
48+
const maxScroll = Math.max( scrollWidth - clientWidth, 0 );
49+
const direction =
50+
listEl.dir ||
51+
( typeof window !== 'undefined'
52+
? window.getComputedStyle( listEl ).direction
53+
: 'ltr' );
54+
55+
const scrollFromStart =
56+
direction === 'rtl' && scrollLeft < 0
57+
? // In RTL layouts, scrollLeft is typically 0 at the visual "start"
58+
// (right edge) and becomes negative toward the "end" (left edge).
59+
// Normalize value for correct first/last detection logic.
60+
-scrollLeft
61+
: scrollLeft;
62+
63+
// Use SCROLL_EPSILON to handle subpixel rendering differences.
64+
setOverflow( {
65+
first: scrollFromStart > SCROLL_EPSILON,
66+
last: scrollFromStart < maxScroll - SCROLL_EPSILON,
67+
isScrolling: scrollWidth > clientWidth,
68+
} );
69+
};
70+
71+
const resizeObserver = new ResizeObserver( measureOverflow );
72+
resizeObserver.observe( listEl );
73+
74+
let scrollTick = false;
75+
const throttleMeasureOverflowOnScroll = () => {
76+
if ( ! scrollTick ) {
77+
requestAnimationFrame( () => {
78+
measureOverflow();
79+
scrollTick = false;
80+
} );
81+
scrollTick = true;
82+
}
83+
};
84+
listEl.addEventListener(
85+
'scroll',
86+
throttleMeasureOverflowOnScroll,
87+
{ passive: true }
88+
);
89+
90+
// Initial check.
91+
measureOverflow();
92+
93+
return () => {
94+
listEl.removeEventListener(
95+
'scroll',
96+
throttleMeasureOverflowOnScroll
97+
);
98+
resizeObserver.disconnect();
99+
};
100+
}, [ listEl ] );
101+
102+
const mergedListRef = useMergeRefs( [
103+
forwardedRef,
104+
( el: HTMLDivElement | null ) => setListEl( el ),
105+
] );
106+
107+
return (
108+
<_Tabs.List
109+
ref={ mergedListRef }
110+
activateOnFocus={ activateOnFocus }
111+
data-select-on-move={ activateOnFocus ? 'true' : 'false' }
112+
className={ clsx(
113+
styles.tablist,
114+
overflow.first && styles[ 'is-overflowing-first' ],
115+
overflow.last && styles[ 'is-overflowing-last' ],
116+
styles[ `is-${ variant }-variant` ],
117+
className
118+
) }
119+
{ ...otherProps }
120+
tabIndex={
121+
otherProps.tabIndex ??
122+
( overflow.isScrolling ? -1 : undefined )
123+
}
124+
>
125+
{ children }
126+
<_Tabs.Indicator className={ styles.indicator } />
127+
</_Tabs.List>
128+
);
129+
}
130+
);

packages/ui/src/tabs/panel.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { forwardRef } from '@wordpress/element';
2+
import clsx from 'clsx';
3+
import { Tabs as _Tabs } from '@base-ui/react/tabs';
4+
import styles from './style.module.css';
5+
import type { TabPanelProps } from './types';
6+
7+
/**
8+
* A panel displayed when the corresponding tab is active.
9+
*
10+
* `Tabs` is a collection of React components that combine to render
11+
* an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).
12+
*/
13+
export const Panel = forwardRef< HTMLDivElement, TabPanelProps >(
14+
function TabPanel( { className, ...otherProps }, forwardedRef ) {
15+
return (
16+
<_Tabs.Panel
17+
ref={ forwardedRef }
18+
className={ clsx( styles.tabpanel, className ) }
19+
{ ...otherProps }
20+
/>
21+
);
22+
}
23+
);

packages/ui/src/tabs/root.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { forwardRef } from '@wordpress/element';
2+
import { Tabs as _Tabs } from '@base-ui/react/tabs';
3+
import type { TabRootProps } from './types';
4+
5+
/**
6+
* Groups the tabs and the corresponding panels.
7+
*
8+
* `Tabs` is a collection of React components that combine to render
9+
* an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/).
10+
*/
11+
export const Root = forwardRef< HTMLDivElement, TabRootProps >(
12+
function TabsRoot( { ...otherProps }, forwardedRef ) {
13+
return <_Tabs.Root ref={ forwardedRef } { ...otherProps } />;
14+
}
15+
);
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { Meta } from '@storybook/addon-docs/blocks';
2+
3+
<Meta title="Design System/Components/Tabs/Best Practices" />
4+
5+
# Tabs
6+
7+
## Usage
8+
9+
### Uncontrolled Mode
10+
11+
`Tabs` can be used in an uncontrolled mode, where the component manages its own state. In this mode, the `defaultValue` prop can be used to set the initially selected tab.
12+
13+
```jsx
14+
import { Tabs } from '@wordpress/ui';
15+
16+
const MyUncontrolledTabs = () => (
17+
<Tabs.Root
18+
onValueChange={ ( tab ) => console.log( 'New selected tab: ', tab ) }
19+
defaultValue="tab2"
20+
>
21+
<Tabs.List>
22+
<Tabs.Tab value="tab1">Tab 1</Tabs.Tab>
23+
<Tabs.Tab value="tab2">Tab 2</Tabs.Tab>
24+
<Tabs.Tab value="tab3">Tab 3</Tabs.Tab>
25+
</Tabs.List>
26+
<Tabs.Panel value="tab1">
27+
<p>Selected tab: Tab 1</p>
28+
</Tabs.Panel>
29+
<Tabs.Panel value="tab2">
30+
<p>Selected tab: Tab 2</p>
31+
</Tabs.Panel>
32+
<Tabs.Panel value="tab3">
33+
<p>Selected tab: Tab 3</p>
34+
</Tabs.Panel>
35+
</Tabs.Root>
36+
);
37+
```
38+
39+
### Controlled Mode
40+
41+
Tabs can also be used in a controlled mode, where the parent component uses the `value` and `onValueChange` props to control tab selection. In this mode, the `defaultValue` prop will be ignored if it is provided. To indicate that no tabs are selected, pass `null` to the `value`.
42+
43+
```tsx
44+
import { useState } from 'react';
45+
import { Tabs } from '@wordpress/ui';
46+
47+
const MyControlledTabs = () => {
48+
const [ selectedTabId, setSelectedTabId ] = useState<
49+
string | undefined | null
50+
>( null );
51+
52+
return (
53+
<Tabs.Root
54+
value={ selectedTabId }
55+
onValueChange={ ( newSelectedTabId ) => {
56+
setSelectedTabId( newSelectedTabId );
57+
console.log( 'Selecting tab', newSelectedTabId );
58+
} }
59+
>
60+
<Tabs.List>
61+
<Tabs.Tab value="tab1">Tab 1</Tabs.Tab>
62+
<Tabs.Tab value="tab2">Tab 2</Tabs.Tab>
63+
<Tabs.Tab value="tab3">Tab 3</Tabs.Tab>
64+
</Tabs.List>
65+
<Tabs.Panel value="tab1">
66+
<p>Selected tab: Tab 1</p>
67+
</Tabs.Panel>
68+
<Tabs.Panel value="tab2">
69+
<p>Selected tab: Tab 2</p>
70+
</Tabs.Panel>
71+
<Tabs.Panel value="tab3">
72+
<p>Selected tab: Tab 3</p>
73+
</Tabs.Panel>
74+
</Tabs.Root>
75+
);
76+
};
77+
```
78+
79+
### Using `Tabs` with links
80+
81+
The semantics implemented by the `Tabs` component don't align well with the semantics needed by a list of links. Furthermore, end users usually expect every link to be tabbable, while `Tabs.List` is a [composite](https://w3c.github.io/aria/#composite) widget acting as a single tab stop.
82+
83+
For these reasons, even if the `Tabs` component is fully extensible, we don't recommend using `Tabs` with links, and we don't currently provide any related Storybook example.
84+
85+
We may provide a dedicated component for tabs-like links in the future based on the feedback received.

0 commit comments

Comments
 (0)