From d5020c6892286dce405c03709b51748dbd3f057c Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 13:00:53 +0100 Subject: [PATCH 01/38] Initial commit --- .cursor/commands/adapt-code-for-repo.md | 3 + .cursor/commands/import-normalize.md | 5 + packages/ui/src/tabs/best-practices.mdx | 84 + packages/ui/src/tabs/index.stories.tsx | 382 ++++ packages/ui/src/tabs/index.ts | 9 + packages/ui/src/tabs/list.tsx | 152 ++ packages/ui/src/tabs/panel.tsx | 34 + packages/ui/src/tabs/root.tsx | 22 + packages/ui/src/tabs/style.module.css | 288 +++ packages/ui/src/tabs/tab.tsx | 43 + packages/ui/src/tabs/test/index.test.tsx | 39 + packages/ui/src/tabs/test/index.tsx | 2257 ++++++++++++++++++++++ packages/ui/src/tabs/types.ts | 42 + 13 files changed, 3360 insertions(+) create mode 100644 .cursor/commands/adapt-code-for-repo.md create mode 100644 .cursor/commands/import-normalize.md create mode 100644 packages/ui/src/tabs/best-practices.mdx create mode 100644 packages/ui/src/tabs/index.stories.tsx create mode 100644 packages/ui/src/tabs/index.ts create mode 100644 packages/ui/src/tabs/list.tsx create mode 100644 packages/ui/src/tabs/panel.tsx create mode 100644 packages/ui/src/tabs/root.tsx create mode 100644 packages/ui/src/tabs/style.module.css create mode 100644 packages/ui/src/tabs/tab.tsx create mode 100644 packages/ui/src/tabs/test/index.test.tsx create mode 100644 packages/ui/src/tabs/test/index.tsx create mode 100644 packages/ui/src/tabs/types.ts diff --git a/.cursor/commands/adapt-code-for-repo.md b/.cursor/commands/adapt-code-for-repo.md new file mode 100644 index 00000000000000..bb9b4c4fbf28a7 --- /dev/null +++ b/.cursor/commands/adapt-code-for-repo.md @@ -0,0 +1,3 @@ +# adapt-code-for-repo + +I just added these file(s) from a different repo. Make the minimal changes necessary to make it work in this repo. Give higher priority to package-specific conventions if necessary. Also, run the `import-normalize` command to normalize the imports. diff --git a/.cursor/commands/import-normalize.md b/.cursor/commands/import-normalize.md new file mode 100644 index 00000000000000..56c41d93fb671c --- /dev/null +++ b/.cursor/commands/import-normalize.md @@ -0,0 +1,5 @@ +# import-normalize + +1. Remove the doc comments preceding the import statements. +2. Make the minimal changes required to make [this ordering rule lint](../../.eslintrc.strict.js) pass. +3. Remove any extra blank lines between import statements. diff --git a/packages/ui/src/tabs/best-practices.mdx b/packages/ui/src/tabs/best-practices.mdx new file mode 100644 index 00000000000000..464f5919bafba6 --- /dev/null +++ b/packages/ui/src/tabs/best-practices.mdx @@ -0,0 +1,84 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + +import * as TabsStories from './index.stories'; + + + +# Tabs + +## Usage + +### Uncontrolled Mode + +`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. + +```jsx +import { Tabs } from '@automattic/design-system'; + +const MyUncontrolledTabs = () => ( + + + Tab 1 + Tab 2 + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+); +``` + +### Controlled Mode + +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`. + +```tsx +import { Tabs } from '@automattic/design-system'; + +const [ selectedTabId, setSelectedTabId ] = useState< + string | undefined | null +>( null ); + +const MyControlledTabs = () => ( + { + setSelectedTabId( newSelectedTabId ); + console.log( 'Selecting tab', newSelectedTabId ); + } } + > + + Tab 1 + Tab 2 + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+); +``` + +### Using `Tabs` with links + +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. + +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. + +We may provide a dedicated component for tabs-like links in the future based on the feedback received. diff --git a/packages/ui/src/tabs/index.stories.tsx b/packages/ui/src/tabs/index.stories.tsx new file mode 100644 index 00000000000000..0dbc0891b1c23a --- /dev/null +++ b/packages/ui/src/tabs/index.stories.tsx @@ -0,0 +1,382 @@ +/** + * External dependencies + */ +import { fn } from 'storybook/test'; +import { useState, cloneElement } from 'react'; + +/** + * WordPress dependencies + */ +import { wordpress, link, more } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { Tooltip, Tabs } from '..'; +import type { Meta, StoryObj } from '@storybook/react-vite'; + +const meta: Meta< typeof Tabs.Root > = { + title: 'Design System/Tabs', + component: Tabs.Root, + subcomponents: { + 'Tabs.List': Tabs.List, + 'Tabs.Tab': Tabs.Tab, + 'Tabs.Panel': Tabs.Panel, + }, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, + args: { + onValueChange: fn(), + }, +}; +export default meta; + +const ThemedParagraph = ( { children }: { children: React.ReactNode } ) => { + return ( +

+ { children } +

+ ); +}; + +export const Default: StoryObj< typeof Tabs.Root > = { + args: { + defaultValue: 'tab1', + children: ( + <> + + Tab 1 + Tab 2 + Tab 3 + + + Selected tab: Tab 1 + + + Selected tab: Tab 2 + + + Selected tab: Tab 3 + + + ), + }, +}; + +export const Compact: StoryObj< typeof Tabs.Root > = { + args: { + ...Default.args, + children: ( + <> + + Tab 1 + Tab 2 + Tab 3 + + + Selected tab: Tab 1 + + + Selected tab: Tab 2 + + + Selected tab: Tab 3 + + + ), + }, +}; + +export const SizeAndOverflowPlayground: StoryObj< typeof Tabs.Root > = { + render: function SizeAndOverflowPlayground( props ) { + const [ fullWidth, setFullWidth ] = useState( false ); + return ( +
+
+

+ This story helps understand how the TabList component + behaves under different conditions. The container below + (with the dotted red border) can be horizontally + resized, and it has a bit of padding to be out of the + way of the TabList. +

+

+ The button will toggle between full width (adding{ ' ' } + width: 100%) and the default width. +

+

Try the following:

+
    +
  • + Small container that causes tabs to + overflow with scroll. +
  • +
  • + Large container that exceeds the + normal width of the tabs. +
      +
    • + + With width: 100% + { ' ' } + set on the TabList (tabs fill up the space). +
    • +
    • + + Without width: 100% + { ' ' } + (defaults to auto) set on the + TabList (tabs take up space proportional to + their content). +
    • +
    +
  • +
+
+ + + + + Label with multiple words + + Short + + Hippopotomonstrosesquippedaliophobia + + Tab 4 + Tab 5 + + + + Selected tab: Tab 1 + + (Label with multiple words) + + + + Selected tab: Tab 2 + (Short) + + + Selected tab: Tab 3 + + (Hippopotomonstrosesquippedaliophobia) + + + + Selected tab: Tab 4 + + + Selected tab: Tab 5 + + +
+ ); + }, + args: { + ...Default.args, + defaultValue: 'tab4', + }, +}; + +export const Vertical: StoryObj< typeof Tabs.Root > = { + args: { + ...Default.args, + orientation: 'vertical', + style: { + minWidth: '320px', + display: 'grid', + gridTemplateColumns: '120px 1fr', + gap: '24px', + }, + }, +}; + +export const WithDisabledTab: StoryObj< typeof Tabs.Root > = { + args: { + ...Default.args, + defaultValue: 'tab3', + children: ( + <> + + + Tab 1 + + Tab 2 + Tab 3 + + + Selected tab: Tab 1 + + + Selected tab: Tab 2 + + + Selected tab: Tab 3 + + + ), + }, +}; + +const LinkIcon = ( props: React.SVGProps< SVGSVGElement > ) => { + return cloneElement( link, props ); +}; + +const MoreIcon = ( props: React.SVGProps< SVGSVGElement > ) => { + return cloneElement( more, props ); +}; + +const WordpressIcon = ( props: React.SVGProps< SVGSVGElement > ) => { + return cloneElement( wordpress, props ); +}; + +const tabWithIconsData = [ + { + value: 'tab1', + label: 'Tab one', + icon: WordpressIcon, + }, + { + value: 'tab2', + label: 'Tab two', + icon: LinkIcon, + }, + { + value: 'tab3', + label: 'Tab three', + icon: MoreIcon, + }, +]; +export const WithTabIconsAndTooltips: StoryObj< typeof Tabs.Root > = { + args: { + ...Default.args, + children: ( + <> + + { tabWithIconsData.map( + ( { value, label, icon: Icon } ) => ( + + } + > + { /* TODO: potentially refactor with new Icon component */ } + + + + { label } + + + ) + ) } + + { tabWithIconsData.map( ( { value, label } ) => ( + + + Selected tab: { label } + + + ) ) } + + ), + }, +}; + +export const WithPanelsAlwaysMounted: StoryObj< typeof Tabs.Root > = { + args: { + ...Default.args, + children: ( + <> + + Tab 1 + Tab 2 + Tab 3 + + + Selected tab: Tab 1 + + + Selected tab: Tab 2 + + + Selected tab: Tab 3 + + + ), + }, +}; + +export const WithNonFocusablePanels: StoryObj< typeof Tabs.Root > = { + args: { + ...Default.args, + children: ( + <> + + Tab 1 + Tab 2 + Tab 3 + + + Selected tab: Tab 1 + + This tabpanel is not focusable, therefore tabbing into + it will focus its first tabbable child. + + + + + Selected tab: Tab 2 + + This tabpanel is not focusable, therefore tabbing into + it will focus its first tabbable child. + + + + + Selected tab: Tab 3 + + This tabpanel is not focusable, therefore tabbing into + it will focus its first tabbable child. + + + + + ), + }, +}; diff --git a/packages/ui/src/tabs/index.ts b/packages/ui/src/tabs/index.ts new file mode 100644 index 00000000000000..fc5645bc61288d --- /dev/null +++ b/packages/ui/src/tabs/index.ts @@ -0,0 +1,9 @@ +/** + * Internal dependencies + */ +import { Root } from './root'; +import { List } from './list'; +import { Panel } from './panel'; +import { Tab } from './tab'; + +export { Root, List, Panel, Tab }; diff --git a/packages/ui/src/tabs/list.tsx b/packages/ui/src/tabs/list.tsx new file mode 100644 index 00000000000000..81432413fae157 --- /dev/null +++ b/packages/ui/src/tabs/list.tsx @@ -0,0 +1,152 @@ +/** + * External dependencies + */ +import { + forwardRef, + useState, + useEffect, + isValidElement, + cloneElement, +} from 'react'; +import clsx from 'clsx'; +import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; + +/** + * WordPress dependencies + */ +import { useMergeRefs } from '@wordpress/compose'; + +/** + * Internal dependencies + */ +import styles from './style.module.css'; +import type { TabListProps } from './types'; + +const DEFAULT_SCROLL_MARGIN = 0; + +/** + * Groups the individual tab buttons. + * + * `Tabs` is a collection of React components that combine to render + * an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/). + */ +export const List = forwardRef< HTMLDivElement, TabListProps >( + function TabList( + { + children, + density = 'default', + className, + activateOnFocus, + render, + ...otherProps + }, + forwardedRef + ) { + const [ listEl, setListEl ] = useState< HTMLDivElement | null >( null ); + const [ overflow, setOverflow ] = useState< { + first: boolean; + last: boolean; + } >( { + first: false, + last: false, + } ); + + // Check if list is overflowing when it scrolls or resizes. + useEffect( () => { + if ( ! listEl ) { + return; + } + + // Grab a local reference to the list element to ensure it remains stable + // during the effect and the event listeners. + const localListEl = listEl; + + function measureOverflow() { + if ( ! localListEl ) { + setOverflow( { + first: false, + last: false, + } ); + return; + } + + const { scrollWidth, clientWidth, scrollLeft } = localListEl; + + setOverflow( { + first: scrollLeft > DEFAULT_SCROLL_MARGIN, + last: + scrollLeft + clientWidth < + scrollWidth - DEFAULT_SCROLL_MARGIN, + } ); + } + + const resizeObserver = new ResizeObserver( measureOverflow ); + resizeObserver.observe( localListEl ); + let scrollTick = false; + function throttleMeasureOverflowOnScroll() { + if ( ! scrollTick ) { + requestAnimationFrame( () => { + measureOverflow(); + scrollTick = false; + } ); + scrollTick = true; + } + } + localListEl.addEventListener( + 'scroll', + throttleMeasureOverflowOnScroll, + { passive: true } + ); + + // Initial check. + measureOverflow(); + + return () => { + localListEl.removeEventListener( + 'scroll', + throttleMeasureOverflowOnScroll + ); + resizeObserver.disconnect(); + }; + }, [ listEl ] ); + + const mergedListRef = useMergeRefs( [ + forwardedRef, + ( el: HTMLDivElement | null ) => setListEl( el ), + ] ); + + return ( + { + // Fallback to -1 to prevent browsers from making the tablist + // tabbable when it is a scrolling container. + const newProps = { + ...props, + tabIndex: props.tabIndex ?? -1, + }; + + if ( isValidElement( render ) ) { + return cloneElement( render, newProps ); + } else if ( typeof render === 'function' ) { + return render( newProps, state ); + } + return
; + } } + { ...otherProps } + > + { children } + + + ); + } +); diff --git a/packages/ui/src/tabs/panel.tsx b/packages/ui/src/tabs/panel.tsx new file mode 100644 index 00000000000000..156a97b66038d5 --- /dev/null +++ b/packages/ui/src/tabs/panel.tsx @@ -0,0 +1,34 @@ +/** + * External dependencies + */ +import { forwardRef } from 'react'; +import clsx from 'clsx'; +import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; + +/** + * Internal dependencies + */ +import styles from './style.module.css'; +import type { TabPanelProps } from './types'; + +/** + * A panel displayed when the corresponding tab is active. + * + * `Tabs` is a collection of React components that combine to render + * an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/). + */ +export const Panel = forwardRef< HTMLDivElement, TabPanelProps >( + function TabPanel( + { className, focusable = true, tabIndex, ...otherProps }, + forwardedRef + ) { + return ( + + ); + } +); diff --git a/packages/ui/src/tabs/root.tsx b/packages/ui/src/tabs/root.tsx new file mode 100644 index 00000000000000..41ea4b07237e17 --- /dev/null +++ b/packages/ui/src/tabs/root.tsx @@ -0,0 +1,22 @@ +/** + * External dependencies + */ +import { forwardRef } from 'react'; +import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; + +/** + * Internal dependencies + */ +import type { TabRootProps } from './types'; + +/** + * Groups the tabs and the corresponding panels. + * + * `Tabs` is a collection of React components that combine to render + * an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/). + */ +export const Root = forwardRef< HTMLDivElement, TabRootProps >( + function TabsRoot( { ...otherProps }, forwardedRef ) { + return ; + } +); diff --git a/packages/ui/src/tabs/style.module.css b/packages/ui/src/tabs/style.module.css new file mode 100644 index 00000000000000..2a44cf76d3c7cd --- /dev/null +++ b/packages/ui/src/tabs/style.module.css @@ -0,0 +1,288 @@ +@layer wp-ui-utilities, wp-ui-components, wp-ui-compositions, wp-ui-overrides; + +@layer wp-ui-components { + .tablist { + display: flex; + align-items: stretch; + overflow-x: auto; + + &[data-orientation="horizontal"] { + width: fit-content; + } + + --direction-factor: 1; + --direction-start: left; + --direction-end: right; + + &:dir( rtl ) { + --direction-factor: -1; + --direction-start: right; + --direction-end: left; + } + + position: relative; + + &[data-orientation="horizontal"] { + --fade-width: 4rem; + --fade-gradient-base: transparent 0%, #000 var( --fade-width ); + --fade-gradient-composed: var( --fade-gradient-base ), #000 60%, + transparent 50%; + + &.is-overflowing-first { + mask-image: linear-gradient( + to var( --direction-end ), + var( --fade-gradient-base ) + ); + } + + &.is-overflowing-last { + mask-image: linear-gradient( + to var( --direction-start ), + var( --fade-gradient-base ) + ); + } + + &.is-overflowing-first.is-overflowing-last { + mask-image: linear-gradient( + to right, + var( --fade-gradient-composed ) + ), + linear-gradient( to left, var( --fade-gradient-composed ) ); + } + + &.has-compact-density { + gap: 1rem; + } + } + + &[data-orientation="vertical"] { + flex-direction: column; + } + } + + .indicator { + @media not ( prefers-reduced-motion ) { + transition-property: translate, width, height, border-radius, + border-block; + transition-duration: 0.2s; + transition-timing-function: ease-out; + } + + position: absolute; + pointer-events: none; + + /* Windows high contrast mode. */ + outline: 2px solid transparent; + outline-offset: -1px; + + &[data-orientation="horizontal"] { + z-index: 1; + left: 0; + bottom: 0; + height: var( --wpds-border-width-interactive-focus ); + + width: var( --active-tab-width ); + translate: var( --active-tab-left ) 0; + background-color: var( + --wpds-color-stroke-interactive-neutral-strong + ); + } + + &[data-orientation="vertical"] { + z-index: 0; + border-radius: var( --wpds-border-radius-surface-sm ); + top: 0; + left: 50%; + width: 100%; + height: var( --active-tab-height ); + translate: -50% var( --active-tab-top ); + background-color: var( + --wpds-color-bg-interactive-neutral-weak-active + ); + } + + .tablist[data-select-on-move="true"]:has( :focus-visible ) + &[data-orientation="vertical"] { + box-sizing: border-box; + border: var( --wpds-border-width-interactive-focus ) solid + var( --wpds-color-stroke-focus-brand ); + } + } + + .tab { + /* Resets */ + border-radius: 0; + background: transparent; + border: none; + box-shadow: none; + outline: none; /* Focus ring applied to the ::after pseudo-element */ + padding: 0; + + /* Positioning */ + z-index: 1; + flex: 1 0 auto; + position: relative; + display: flex; + align-items: center; + + /* Appearance */ + cursor: pointer; + + /* Typography (TODO: replace with theme tokens when available) */ + font-family: sans-serif; + font-size: 13px; + white-space: nowrap; + + /* Characters in some languages (e.g. Japanese) may have a native higher line-height. */ + + line-height: 1.2; + font-weight: 400; + color: var( --wpds-color-fg-interactive-neutral ); + + &[data-disabled] { + cursor: default; + color: var( --wpds-color-fg-interactive-neutral-disabled ); + + @media ( forced-colors: active ) { + color: GrayText; + } + } + + &:not( [data-disabled] ):is( :hover, :focus-visible ) { + color: var( --wpds-color-fg-interactive-neutral-active ); + } + + /* Focus indicator. */ + &::after { + position: absolute; + z-index: -1; + pointer-events: none; + + /* Outline works for Windows high contrast mode as well. */ + outline: var( --wpds-border-width-interactive-focus ) solid + var( --wpds-color-stroke-focus-brand ); + border-radius: var( --wpds-border-radius-surface-sm ); + + /* Animation */ + opacity: 0; + + @media not ( prefers-reduced-motion ) { + transition: opacity 0.1s linear; + } + } + + &:focus-visible::after { + opacity: 1; + } + + [data-orientation="horizontal"] & { + padding-inline: calc( + 4 * var( --wpds-dimension-base ) + ); /* TODO: Use or create new control/interactive padding token */ + height: calc( + 12 * var( --wpds-dimension-base ) + ); /* TODO: Can we eliminate or hard-code? */ + scroll-margin: 24px; + + &::after { + content: ""; + inset: calc( + 3 * var( --wpds-dimension-base ) + ); /* TODO: Use or create new control/interactive padding token */ + } + } + + .has-compact-density[data-orientation="horizontal"] & { + padding-inline: 0; + + &::after { + /* + * Add enough inset to prevent the focus ring (which is 1.5px thick) + * from being visually clipped by the tablist. + */ + inset-inline: 2px; + } + } + + [data-orientation="vertical"] & { + padding: calc( 2 * var( --wpds-dimension-base ) ) + calc( 3 * var( --wpds-dimension-base ) ); /* TODO: Use or create new control/interactive padding token */ + min-height: calc( + 10 * var( --wpds-dimension-base ) + ); /* TODO: Can we eliminate or hard-code? */ + } + + [data-orientation="vertical"][data-select-on-move="false"] &::after { + content: ""; + inset: var( + --wpds-border-width-interactive-focus + ); /* TODO: Use or create new control/interactive padding token */ + } + } + + .tab__children { + flex-grow: 1; + + display: flex; + align-items: center; + + [data-orientation="horizontal"] & { + justify-content: center; + } + + [data-orientation="vertical"] & { + justify-content: start; + } + } + + .tab__chevron { + fill: currentColor; + height: 24px; + flex-shrink: 0; + margin-inline-end: calc( + var( --wpds-dimension-base ) * -1 + ); /* TODO: Use or create new control/interactive padding token */ + + [data-orientation="horizontal"] & { + display: none; + } + opacity: 0; + + [role="tab"]:is( [aria-selected="true"], :focus-visible, :hover ) & { + opacity: 1; + } + + /* + * The chevron is transitioned into existence when selectOnMove is enabled, + * because otherwise it looks jarring, as it shows up outside of the focus + * indicator that's being animated at the same time. + */ + @media not ( prefers-reduced-motion ) { + [data-select-on-move="true"] + [role="tab"]:is( [aria-selected="true"] ) + & { + transition: opacity 0.15s 0.15s linear; + } + } + + &:dir( rtl ) { + rotate: 180deg; + } + } + + .tabpanel { + &:focus { + box-shadow: none; + outline: none; + } + + &:focus-visible { + box-shadow: 0 0 0 var( --wpds-border-width-interactive-focus ) + var( --wpds-color-stroke-focus-brand ); + + /* Windows high contrast mode. */ + outline: 2px solid transparent; + outline-offset: 0; + } + } +} diff --git a/packages/ui/src/tabs/tab.tsx b/packages/ui/src/tabs/tab.tsx new file mode 100644 index 00000000000000..b0fd2a2a0735e0 --- /dev/null +++ b/packages/ui/src/tabs/tab.tsx @@ -0,0 +1,43 @@ +/** + * External dependencies + */ +import { forwardRef, cloneElement } from 'react'; +import clsx from 'clsx'; +import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; + +/** + * WordPress dependencies + */ +import { chevronRight } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import styles from './style.module.css'; +import type { TabProps } from './types'; + +const ChevronRight = ( props: React.SVGProps< SVGSVGElement > ) => { + return cloneElement( chevronRight, props ); +}; + +/** + * An individual interactive tab button that toggles the corresponding panel. + * + * `Tabs` is a collection of React components that combine to render + * an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/). + */ +export const Tab = forwardRef< HTMLButtonElement, TabProps >( function Tab( + { className, children, ...otherProps }, + forwardedRef +) { + return ( + + { children } + + + ); +} ); diff --git a/packages/ui/src/tabs/test/index.test.tsx b/packages/ui/src/tabs/test/index.test.tsx new file mode 100644 index 00000000000000..be926db2eb5188 --- /dev/null +++ b/packages/ui/src/tabs/test/index.test.tsx @@ -0,0 +1,39 @@ +/** + * External dependencies + */ +import { render } from '@testing-library/react'; +import { createRef } from 'react'; + +/** + * Internal dependencies + */ +import * as Tabs from '../index'; + +describe( 'Tabs', () => { + it( 'forwards ref', () => { + const rootRef = createRef< HTMLDivElement >(); + const listRef = createRef< HTMLDivElement >(); + const tabRef = createRef< HTMLButtonElement >(); + const panelRef = createRef< HTMLDivElement >(); + + render( + + + + Tab 1 + + Tab 2 + + + Panel 1 content + + Panel 2 content + + ); + + expect( rootRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( listRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( tabRef.current ).toBeInstanceOf( HTMLButtonElement ); + expect( panelRef.current ).toBeInstanceOf( HTMLDivElement ); + } ); +} ); diff --git a/packages/ui/src/tabs/test/index.tsx b/packages/ui/src/tabs/test/index.tsx new file mode 100644 index 00000000000000..046a079da87407 --- /dev/null +++ b/packages/ui/src/tabs/test/index.tsx @@ -0,0 +1,2257 @@ +/** + * External dependencies + */ +import React, { useEffect, useState } from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DirectionProvider } from '@base-ui/react/direction-provider'; + +/** + * Internal dependencies + */ +import { Tabs } from '../..'; +import type { TabRootProps } from '../types'; + +type Tab = { + value: string; + title: string; + content: React.ReactNode; + tab: { + className?: string; + disabled?: boolean; + }; + tabpanel?: { + focusable?: boolean; + }; +}; + +const TABS: Tab[] = [ + { + value: 'alpha', + title: 'Alpha', + content: 'Selected tab: Alpha', + tab: { className: 'alpha-class' }, + }, + { + value: 'beta', + title: 'Beta', + content: 'Selected tab: Beta', + tab: { className: 'beta-class' }, + }, + { + value: 'gamma', + title: 'Gamma', + content: 'Selected tab: Gamma', + tab: { className: 'gamma-class' }, + }, +]; + +const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => + tabObj.value === 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj +); + +const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) => + tabObj.value === 'beta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj +); + +const TABS_WITH_DELTA: Tab[] = [ + ...TABS, + { + value: 'delta', + title: 'Delta', + content: 'Selected tab: Delta', + tab: { className: 'delta-class' }, + }, +]; + +const UncontrolledTabs = ( { + tabs, + selectOnMove, + ...props +}: Omit< TabRootProps, 'children' | 'tabs' > & { + tabs: Tab[]; + selectOnMove?: boolean; +} ) => { + return ( + + + { tabs.map( ( tabObj, index ) => ( + + { tabObj.title } + + ) ) } + + { tabs.map( ( tabObj, index ) => ( + + { tabObj.content } + + ) ) } + + ); +}; + +const ControlledTabs = ( { + tabs, + selectOnMove, + ...props +}: Omit< TabRootProps, 'children' | 'tabs' > & { + tabs: Tab[]; + selectOnMove?: boolean; +} ) => { + const [ value, setValue ] = useState( props.value ?? null ); + + useEffect( () => { + setValue( props.value ?? null ); + }, [ props.value ] ); + + return ( + { + setValue( selectedId ); + props.onValueChange?.( selectedId, event ); + } } + > + + { tabs.map( ( tabObj, index ) => ( + + { tabObj.title } + + ) ) } + + { tabs.map( ( tabObj, index ) => ( + + { tabObj.content } + + ) ) } + + ); +}; + +let originalGetClientRects: typeof window.HTMLElement.prototype.getClientRects; +let originalScrollTo: typeof Element.prototype.scrollTo; + +async function waitForComponentToBeInitializedWithSelectedTab( + selectedTabName: string | undefined +) { + if ( ! selectedTabName ) { + // Wait for the tablist to be tabbable as a mean to know + // that tabs has finished initializing. + await waitFor( () => + expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( + 'tabindex', + expect.stringMatching( /^(0|-1)$/ ) + ) + ); + // No initially selected tabs or tabpanels. + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + await waitFor( () => + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument() + ); + } else { + // Waiting for a tab to be selected is a sign that the component + // has fully initialized. + expect( + await screen.findByRole( 'tab', { + selected: true, + name: selectedTabName, + } ) + ).toBeVisible(); + // The corresponding tabpanel is also shown. + expect( + screen.getByRole( 'tabpanel', { + name: selectedTabName, + } ) + ).toBeVisible(); + } +} + +describe( 'Tabs', () => { + beforeAll( () => { + originalGetClientRects = window.HTMLElement.prototype.getClientRects; + // Mocking `getClientRects()` is necessary to pass a check performed by + // the `focus.tabbable.find()` and by the `focus.focusable.find()` functions + // from the `@wordpress/dom` package. + // @ts-expect-error We're not trying to comply to the DOM spec, only mocking + window.HTMLElement.prototype.getClientRects = function () { + return [ 'trick-jsdom-into-having-size-for-element-rect' ]; + }; + + // Mock scrollTo since it's not available in JSDOM + originalScrollTo = Element.prototype.scrollTo; + Element.prototype.scrollTo = jest.fn(); + } ); + + afterAll( () => { + window.HTMLElement.prototype.getClientRects = originalGetClientRects; + Element.prototype.scrollTo = originalScrollTo; + } ); + + describe( 'Adherence to spec and basic behavior', () => { + it( 'should apply the correct roles, semantics and attributes', async () => { + render( + + + One + Two + Three + + First panel + Second panel + Third panel + + ); + + await waitForComponentToBeInitializedWithSelectedTab( 'One' ); + + const tabList = screen.getByRole( 'tablist' ); + const allTabs = screen.getAllByRole( 'tab' ); + const allTabpanels = screen.getAllByRole( 'tabpanel' ); + + expect( tabList ).toBeVisible(); + // Since 'horizontal' is the default orientation, no need to set it. + expect( tabList ).not.toHaveAttribute( 'aria-orientation' ); + + expect( allTabs ).toHaveLength( TABS.length ); + + // Only 1 tab panel is accessible — the one associated with the + // selected tab. The selected `tab` aria-controls the active + // `tabpanel`, which is `aria-labelledby` the selected `tab`. + expect( allTabpanels ).toHaveLength( 1 ); + + expect( allTabpanels[ 0 ] ).toBeVisible(); + + expect( allTabs[ 0 ] ).toHaveAttribute( + 'aria-controls', + allTabpanels[ 0 ].getAttribute( 'id' ) + ); + expect( allTabpanels[ 0 ] ).toHaveAttribute( + 'aria-labelledby', + allTabs[ 0 ].getAttribute( 'id' ) + ); + } ); + + it( 'should associate each `tab` with the correct `tabpanel`, even if they are not rendered in the same order', async () => { + const TABS_WITH_DELTA_REVERSED = [ ...TABS_WITH_DELTA ].reverse(); + + const user = userEvent.setup(); + + render( + + + { TABS_WITH_DELTA.map( ( tabObj, index ) => ( + + { tabObj.title } + + ) ) } + + { TABS_WITH_DELTA_REVERSED.map( ( tabObj, index ) => ( + + { tabObj.content } + + ) ) } + + ); + + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // Select Beta, make sure the correct tabpanel is rendered + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + // Select Gamma, make sure the correct tabpanel is rendered + await user.click( screen.getByRole( 'tab', { name: 'Gamma' } ) ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + // Select Delta, make sure the correct tabpanel is rendered + await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Delta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Delta', + } ) + ).toBeVisible(); + } ); + + it( "should apply the tab's `className` to the tab button", async () => { + render( ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveClass( 'alpha-class' ); + expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveClass( + 'beta-class' + ); + expect( screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveClass( + 'gamma-class' + ); + } ); + } ); + + describe( 'pointer interactions', () => { + it( 'should select a tab when clicked', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + render( + + ); + + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // Click on Beta, make sure beta is the selected tab + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + + // Click on Alpha, make sure alpha is the selected tab + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'alpha', + expect.anything() + ); + } ); + + it( 'should not select a disabled tab when clicked', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // Clicking on Beta does not result in beta being selected + // because the tab is disabled. + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); + } ); + } ); + + describe( 'initial tab selection', () => { + describe( 'when a selected tab id is not specified', () => { + describe( 'when left `undefined` [Uncontrolled]', () => { + it( 'should choose the first tab as selected', async () => { + const user = userEvent.setup(); + + render( ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + + // Press tab. The selected tab (alpha) received focus. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // TODO: check that `onValueChange` fired + // once https://github.com/mui/base-ui/issues/2097 is fixed + } ); + + it( 'should choose the first non-disabled tab if the first tab is disabled', async () => { + const user = userEvent.setup(); + + render( + + ); + + // Beta is automatically selected as the selected tab, since alpha is + // disabled. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus. The corresponding + // tabpanel is shown. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + + // TODO: check that `onValueChange` fired + // once https://github.com/mui/base-ui/issues/2097 is fixed + } ); + } ); + describe( 'when `null` [Controlled]', () => { + it( 'should not have a selected tab nor show any tabpanels, make the tablist tabbable and still allow selecting tabs', async () => { + const user = userEvent.setup(); + + render( ); + + // No initially selected tabs or tabpanels. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + + // Press tab to focus and select the first tab (alpha) and + // show the related tabpanel. + await user.keyboard( '{Tab}' ); + await user.keyboard( '{Enter}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } ); + } ); + } ); + + describe( 'when a selected tab id is specified', () => { + describe( 'through the `defaultValue` prop [Uncontrolled]', () => { + it( 'should select the initial tab matching the `defaultValue` prop', async () => { + const user = userEvent.setup(); + + render( + + ); + + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus. The corresponding + // tabpanel is shown. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + + it( 'should select the initial tab matching the `defaultValue` prop even if the tab is disabled', async () => { + const user = userEvent.setup(); + render( + + ); + + // Beta is automatically selected as the selected tab despite being + // disabled, respecting the `defaultValue` prop. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus, since it is + // accessible despite being disabled. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + + it( 'should select the first tab and allow tabbing to it when `defaultValue` prop does not match any known tab', async () => { + const user = userEvent.setup(); + + render( + + ); + + // No initially selected tabs or tabpanels, since the `defaultValue` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + + // Press tab. The first tab receives focus, but it's + // not selected. + await user.keyboard( '{Tab}' ); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + await user.keyboard( '{Enter}' ); + expect( + screen.queryByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } ); + + it( 'should select the first non-disabled tab and allow tabbing to it when `defaultValue` prop does not match any known tab', async () => { + const user = userEvent.setup(); + render( + + ); + + // No initially selected tabs or tabpanels, since the `defaultValue` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The first non-disabled tab receives focus and is selected. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } ); + + it( 'should ignore any changes to the `defaultValue` prop after the first render', async () => { + const mockOnValueChange = jest.fn(); + const consoleErrorSpy = jest + .spyOn( console, 'error' ) + .mockImplementation( () => {} ); + + const { rerender } = render( + + ); + + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Changing the defaultValue prop to gamma should not have any effect. + rerender( + + ); + + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + + expect( consoleErrorSpy ).toHaveBeenCalled(); + expect( consoleErrorSpy ).toHaveBeenCalledWith( + expect.stringContaining( + 'changing the default value state' + ) + ); + + consoleErrorSpy.mockRestore(); + } ); + } ); + + describe( 'through the `value` prop [Controlled]', () => { + describe( 'when the `value` matches an existing tab', () => { + it( 'should choose the initial tab matching the `value`', async () => { + const user = userEvent.setup(); + + render( ); + + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus, since it is + // accessible despite being disabled. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + + it( 'should choose the initial tab matching the `value` even if a `defaultValue` is passed', async () => { + const user = userEvent.setup(); + + render( + + ); + + // Gamma is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Gamma' + ); + + // Press tab. The selected tab (gamma) received focus. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + } ); + + it( 'should choose the initial tab matching the `value` even if the tab is disabled', async () => { + const user = userEvent.setup(); + + render( + + ); + + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus, since it is + // accessible despite being disabled. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + } ); + + describe( "when the `value` doesn't match an existing tab", () => { + it( 'should not have a selected tab nor show any tabpanels, but allow tabbing to the first tab', async () => { + const user = userEvent.setup(); + + render( + + ); + + // No initially selected tabs or tabpanels, since the `value` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + + // Press tab. The first tab receives focus and gets selected. + await user.keyboard( '{Tab}' ); + await user.keyboard( '{Enter}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } ); + + it( 'should not have a selected tab nor show any tabpanels, but allow tabbing to the first tab even when disabled', async () => { + const user = userEvent.setup(); + + render( + + ); + + // No initially selected tabs or tabpanels, since the `value` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + + // Press tab. The first tab receives focus, but it's + // not selected since it's disabled. + await user.keyboard( '{Tab}' ); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + await waitFor( () => + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument() + ); + + // Press right arrow to select the next tab (beta) and + // show the related tabpanel. + await user.keyboard( '{ArrowRight}' ); + await user.keyboard( '{Enter}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } ); + } ); + } ); + } ); + } ); + + describe( 'keyboard interactions', () => { + describe.each( [ + [ 'Uncontrolled', UncontrolledTabs ], + [ 'Controlled', ControlledTabs ], + ] )( '[`%s`]', ( _mode, Component ) => { + it( 'should handle the tablist as one tab stop', async () => { + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // Press tab. The selected tab (alpha) received focus. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // By default the tabpanel should receive focus + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toHaveFocus(); + } ); + + it( 'should not focus the tabpanel container when its `focusable` property is set to `false`', async () => { + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + tabObj.value === 'alpha' + ? { + ...tabObj, + content: ( + <> + Selected Tab: Alpha + + + ), + tabpanel: { focusable: false }, + } + : tabObj + ) } + { ...valueProps } + /> + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // In this case, the tabpanel container is skipped and focus is + // moved directly to its contents + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'button', { + name: 'Alpha Button', + } ) + ).toHaveFocus(); + } ); + + it( 'should select tabs in the tablist when using the left and right arrow keys when automatic tab activation is enabled', async () => { + const mockOnValueChange = jest.fn(); + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Press the right arrow key to select the beta tab + await user.keyboard( '{ArrowRight}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + + // Press the right arrow key to select the gamma tab + await user.keyboard( '{ArrowRight}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'gamma', + expect.anything() + ); + + // Press the left arrow key to select the beta tab + await user.keyboard( '{ArrowLeft}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 3 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + } ); + + it( 'should not automatically select tabs in the tablist when pressing the left and right arrow keys by default (manual tab activation)', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Press the right arrow key to move focus to the beta tab, + // but without selecting it + await user.keyboard( '{ArrowRight}' ); + + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); + + // Press the space key to click the beta tab, and select it. + // The same should be true with any other mean of clicking the tab button + // (ie. mouse click, enter key). + await user.keyboard( '{ }' ); + + await waitFor( () => + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus() + ); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + } ); + + it( 'should not select tabs in the tablist when using the up and down arrow keys, unless the `orientation` prop is set to `vertical`', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + const { rerender } = render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Press the up arrow key, but the focused/selected tab does not change. + await user.keyboard( '{ArrowUp}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); + + // Press the down arrow key, but the focused/selected tab does not change. + await user.keyboard( '{ArrowDown}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); + + // Change the orientation to "vertical" and rerender the component. + rerender( + + ); + + // Pressing the down arrow key now selects the next tab (beta). + await user.keyboard( '{ArrowDown}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + + // Pressing the up arrow key now selects the previous tab (alpha). + await user.keyboard( '{ArrowUp}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'alpha', + expect.anything() + ); + } ); + + it( 'should loop tab focus at the end of the tablist when using arrow keys', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Press the left arrow key to loop around and select the gamma tab + await user.keyboard( '{ArrowLeft}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'gamma', + expect.anything() + ); + + // Press the right arrow key to loop around and select the alpha tab + await user.keyboard( '{ArrowRight}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'alpha', + expect.anything() + ); + } ); + + it( 'should swap the left and right arrow keys when selecting tabs if the writing direction is set to RTL', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Press the left arrow key to select the beta tab + await user.keyboard( '{ArrowLeft}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + + // Press the left arrow key to select the gamma tab + await user.keyboard( '{ArrowLeft}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'gamma', + expect.anything() + ); + + // Press the right arrow key to select the beta tab + await user.keyboard( '{ArrowRight}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 3 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + } ); + + it( 'should focus tabs in the tablist even if disabled', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Pressing the right arrow key moves focus to the beta tab, but alpha + // remains the selected tab because beta is disabled. + await user.keyboard( '{ArrowRight}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); + + // Press the right arrow key to select the gamma tab + await user.keyboard( '{ArrowRight}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'gamma', + expect.anything() + ); + } ); + } ); + + describe( 'When `selectedId` is changed by the controlling component [Controlled]', () => { + describe.each( [ true, false ] )( + 'and automatic tab activation is %s', + ( selectOnMove ) => { + it( 'should continue to handle arrow key navigation properly', async () => { + const user = userEvent.setup(); + + const { rerender } = render( + + ); + + // Beta is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Tab key should focus the currently first tab (if manual activation mode), + // or the currently selected tab (if automatic activation mode). + await user.keyboard( '{Tab}' ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + + rerender( + + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) + ).toHaveFocus(); + + // Arrow left should move focus to the previous tab. + await user.keyboard( '{ArrowLeft}' ); + + await waitFor( () => + expect( + screen.getByRole( 'tab', { + selected: selectOnMove, + name: 'Alpha', + } ) + ).toHaveFocus() + ); + } ); + + it( 'should focus the correct tab when tabbing out and back into the tablist', async () => { + const user = userEvent.setup(); + + const { rerender } = render( + <> + + + + ); + + // Beta is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Tab key should focus the currently selected tab, which is Beta. + await user.keyboard( '{Tab}' ); + await user.keyboard( '{Tab}' ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + + // Change the selected tab to gamma via a controlled update. + rerender( + <> + + + + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) + ).toHaveFocus(); + + // Press shift+tab, move focus to the button before Tabs + await user.keyboard( '{Shift>}{Tab}{/Shift}' ); + expect( + screen.getByRole( 'button', { name: 'Focus me' } ) + ).toHaveFocus(); + + // Press tab, move focus back to the tablist + await user.keyboard( '{Tab}' ); + + expect( + screen.getByRole( 'tab', { + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + } + ); + } ); + } ); + + describe( 'miscellaneous runtime changes', () => { + describe( 'removing a tab', () => { + describe( 'with no explicitly set initial tab', () => { + it( 'should not select a new tab when the selected tab is removed', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const { rerender } = render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Select gamma + await user.click( + screen.getByRole( 'tab', { name: 'Gamma' } ) + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'gamma', + expect.anything() + ); + + // Remove gamma + rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + + // Falls back to the first tab. + expect( + screen.getByRole( 'tab', { + name: 'Alpha', + selected: true, + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe.each( [ + [ 'defaultValue', 'Uncontrolled', UncontrolledTabs ], + [ 'value', 'Controlled', ControlledTabs ], + ] )( + 'when using the `%s` prop [%s]', + ( propName, mode, Component ) => { + it( 'should handle the selected tab being removed', async () => { + const mockOnValueChange = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'gamma', + onValueChange: mockOnValueChange, + }; + + const { rerender } = render( + + ); + + // Gamma is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Gamma' + ); + + // Remove gamma + rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + 2 + ); + + if ( mode === 'Uncontrolled' ) { + // Falls back to the first tab. + expect( + screen.getByRole( 'tab', { + name: 'Alpha', + selected: true, + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // No tab should be selected i.e. it doesn't fall back to first tab. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } + + // Re-add gamma. + rerender( ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + TABS.length + ); + + if ( mode === 'Uncontrolled' ) { + // First tab stays selected. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // Gamma becomes selected again. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + } + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + } ); + + it( `should not fall back to the tab matching the \`${ propName }\` prop when a different selected tab is removed`, async () => { + const mockOnValueChange = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'gamma', + onValueChange: mockOnValueChange, + }; + + const user = userEvent.setup(); + + const { rerender } = render( + + ); + + // Gamma is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Gamma' + ); + + // Select alpha + await user.click( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'alpha', + expect.anything() + ); + + // Remove alpha + rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + 2 + ); + + if ( mode === 'Uncontrolled' ) { + // Falls back to the first available tab. + expect( + screen.getByRole( 'tab', { + name: 'Beta', + selected: true, + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // No tab should be selected i.e. it doesn't fall back to gamma, + // even if it matches the `defaultValue` prop. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } + + // Re-add alpha. Alpha becomes selected again. + rerender( ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + TABS.length + ); + + if ( mode === 'Uncontrolled' ) { + // Beta stays selected. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + } ); + } + ); + } ); + + describe( 'adding a tab', () => { + describe.each( [ + [ 'defaultValue', 'Uncontrolled', UncontrolledTabs ], + [ 'value', 'Controlled', ControlledTabs ], + ] )( + 'when using the `%s` prop [%s]', + ( propName, mode, Component ) => { + it( `should select a newly added tab if it matches the \`${ propName }\` prop`, async () => { + const mockOnValueChange = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'delta', + onValueChange: mockOnValueChange, + }; + + const { rerender } = render( + + ); + + if ( mode === 'Uncontrolled' ) { + // Falls back to the first tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + } + + if ( mode === 'Controlled' ) { + // No initially selected tabs or tabpanels, since the `value` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + } + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + + // Re-render with delta added. + rerender( + + ); + + if ( mode === 'Uncontrolled' ) { + // Alpha stays selected. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // Delta becomes selected + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Delta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Delta', + } ) + ).toBeVisible(); + } + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + } ); + } + ); + } ); + describe( 'a tab becomes disabled', () => { + describe.each( [ + [ 'defaultValue', 'Uncontrolled', UncontrolledTabs ], + [ 'value', 'Controlled', ControlledTabs ], + ] )( + 'when using the `%s` prop [%s]', + ( propName, mode, Component ) => { + it( `should keep the initial tab matching the \`${ propName }\` prop as selected even if it becomes disabled`, async () => { + const mockOnValueChange = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'beta', + onValueChange: mockOnValueChange, + }; + + const { rerender } = render( + + ); + + // Beta is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + + // Re-render with beta disabled. + rerender( + + ); + + // Beta continues to be selected and focused, even if it is disabled. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + // Re-enable beta. + rerender( ); + + // Beta continues to be selected and focused. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + } ); + + it( 'should handle the user-selected tab becoming disabled', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'alpha', + onValueChange: mockOnValueChange, + }; + + const { rerender } = render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Click on beta tab, beta becomes selected. + await user.click( + screen.getByRole( 'tab', { name: 'Beta' } ) + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + + // Re-render with beta disabled. + rerender( + + ); + + if ( mode === 'Uncontrolled' ) { + // Alpha becomes the selected tab. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // Beta continues to be selected, even if it is disabled. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } + + // Re-enable beta. + rerender( ); + + if ( mode === 'Uncontrolled' ) { + // Alpha stays selected. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // Beta continues to be selected and focused. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + } ); + } + ); + } ); + } ); +} ); diff --git a/packages/ui/src/tabs/types.ts b/packages/ui/src/tabs/types.ts new file mode 100644 index 00000000000000..37fe8e16f2a511 --- /dev/null +++ b/packages/ui/src/tabs/types.ts @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +import type { Tabs } from '@base-ui/react/tabs'; + +export type TabRootProps = Omit< Tabs.Root.Props, 'className' > & { + /** + * The CSS class to apply. + */ + className?: Tabs.Root.Props[ 'className' ]; +}; + +export type TabListProps = Omit< Tabs.List.Props, 'className' > & { + /** + * The CSS class to apply. + */ + className?: Tabs.List.Props[ 'className' ]; + /** + * The visual density of the tab list. + * @default "default" + */ + density?: 'compact' | 'default'; +}; + +export type TabProps = Omit< Tabs.Tab.Props, 'className' > & { + /** + * The CSS class to apply. + */ + className?: Tabs.Tab.Props[ 'className' ]; +}; + +export type TabPanelProps = Omit< Tabs.Panel.Props, 'className' > & { + /** + * The CSS class to apply. + */ + className?: Tabs.Panel.Props[ 'className' ]; + /** + * Whether the tab panel should be included in the tab order. + * @default true + */ + focusable?: boolean; +}; From ad737cc93951e1c970ab692f1ef84bf90bef9234 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 13:12:00 +0100 Subject: [PATCH 02/38] Fix imports react => @wordpress/elements import order --- packages/ui/src/tabs/best-practices.mdx | 3 +- packages/ui/src/tabs/index.ts | 5 +- packages/ui/src/tabs/list.tsx | 19 +-- packages/ui/src/tabs/panel.tsx | 9 +- packages/ui/src/tabs/root.tsx | 9 +- .../index.story.tsx} | 151 ++++++++---------- packages/ui/src/tabs/tab.tsx | 13 +- packages/ui/src/tabs/test/index.tsx | 9 +- packages/ui/src/tabs/types.ts | 3 - 9 files changed, 81 insertions(+), 140 deletions(-) rename packages/ui/src/tabs/{index.stories.tsx => stories/index.story.tsx} (80%) diff --git a/packages/ui/src/tabs/best-practices.mdx b/packages/ui/src/tabs/best-practices.mdx index 464f5919bafba6..2da8b196584497 100644 --- a/packages/ui/src/tabs/best-practices.mdx +++ b/packages/ui/src/tabs/best-practices.mdx @@ -1,6 +1,5 @@ import { Meta } from '@storybook/addon-docs/blocks'; - -import * as TabsStories from './index.stories'; +import * as TabsStories from './stories/index.stories'; diff --git a/packages/ui/src/tabs/index.ts b/packages/ui/src/tabs/index.ts index fc5645bc61288d..25defcc93c0150 100644 --- a/packages/ui/src/tabs/index.ts +++ b/packages/ui/src/tabs/index.ts @@ -1,9 +1,6 @@ -/** - * Internal dependencies - */ -import { Root } from './root'; import { List } from './list'; import { Panel } from './panel'; +import { Root } from './root'; import { Tab } from './tab'; export { Root, List, Panel, Tab }; diff --git a/packages/ui/src/tabs/list.tsx b/packages/ui/src/tabs/list.tsx index 81432413fae157..967526681e0cf7 100644 --- a/packages/ui/src/tabs/list.tsx +++ b/packages/ui/src/tabs/list.tsx @@ -1,24 +1,13 @@ -/** - * External dependencies - */ import { + cloneElement, forwardRef, - useState, - useEffect, isValidElement, - cloneElement, -} from 'react'; + useEffect, + useState, +} from '@wordpress/element'; import clsx from 'clsx'; import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; - -/** - * WordPress dependencies - */ import { useMergeRefs } from '@wordpress/compose'; - -/** - * Internal dependencies - */ import styles from './style.module.css'; import type { TabListProps } from './types'; diff --git a/packages/ui/src/tabs/panel.tsx b/packages/ui/src/tabs/panel.tsx index 156a97b66038d5..f8a3eb73bc188d 100644 --- a/packages/ui/src/tabs/panel.tsx +++ b/packages/ui/src/tabs/panel.tsx @@ -1,13 +1,6 @@ -/** - * External dependencies - */ -import { forwardRef } from 'react'; +import { forwardRef } from '@wordpress/element'; import clsx from 'clsx'; import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; - -/** - * Internal dependencies - */ import styles from './style.module.css'; import type { TabPanelProps } from './types'; diff --git a/packages/ui/src/tabs/root.tsx b/packages/ui/src/tabs/root.tsx index 41ea4b07237e17..2c86823011d66d 100644 --- a/packages/ui/src/tabs/root.tsx +++ b/packages/ui/src/tabs/root.tsx @@ -1,12 +1,5 @@ -/** - * External dependencies - */ -import { forwardRef } from 'react'; +import { forwardRef } from '@wordpress/element'; import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; - -/** - * Internal dependencies - */ import type { TabRootProps } from './types'; /** diff --git a/packages/ui/src/tabs/index.stories.tsx b/packages/ui/src/tabs/stories/index.story.tsx similarity index 80% rename from packages/ui/src/tabs/index.stories.tsx rename to packages/ui/src/tabs/stories/index.story.tsx index 0dbc0891b1c23a..4781e266a44474 100644 --- a/packages/ui/src/tabs/index.stories.tsx +++ b/packages/ui/src/tabs/stories/index.story.tsx @@ -1,22 +1,11 @@ -/** - * External dependencies - */ import { fn } from 'storybook/test'; -import { useState, cloneElement } from 'react'; - -/** - * WordPress dependencies - */ -import { wordpress, link, more } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import { Tooltip, Tabs } from '..'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import { useState } from '@wordpress/element'; +// import { link, more, wordpress } from '@wordpress/icons'; +import { Tabs } from '../..'; const meta: Meta< typeof Tabs.Root > = { - title: 'Design System/Tabs', + title: 'Design System/Components/Tabs', component: Tabs.Root, subcomponents: { 'Tabs.List': Tabs.List, @@ -249,74 +238,76 @@ export const WithDisabledTab: StoryObj< typeof Tabs.Root > = { }, }; -const LinkIcon = ( props: React.SVGProps< SVGSVGElement > ) => { - return cloneElement( link, props ); -}; +// const LinkIcon = ( props: React.SVGProps< SVGSVGElement > ) => { +// return cloneElement( link, props ); +// }; -const MoreIcon = ( props: React.SVGProps< SVGSVGElement > ) => { - return cloneElement( more, props ); -}; +// const MoreIcon = ( props: React.SVGProps< SVGSVGElement > ) => { +// return cloneElement( more, props ); +// }; -const WordpressIcon = ( props: React.SVGProps< SVGSVGElement > ) => { - return cloneElement( wordpress, props ); -}; +// const WordpressIcon = ( props: React.SVGProps< SVGSVGElement > ) => { +// return cloneElement( wordpress, props ); +// }; -const tabWithIconsData = [ - { - value: 'tab1', - label: 'Tab one', - icon: WordpressIcon, - }, - { - value: 'tab2', - label: 'Tab two', - icon: LinkIcon, - }, - { - value: 'tab3', - label: 'Tab three', - icon: MoreIcon, - }, -]; -export const WithTabIconsAndTooltips: StoryObj< typeof Tabs.Root > = { - args: { - ...Default.args, - children: ( - <> - - { tabWithIconsData.map( - ( { value, label, icon: Icon } ) => ( - - } - > - { /* TODO: potentially refactor with new Icon component */ } - - - - { label } - - - ) - ) } - - { tabWithIconsData.map( ( { value, label } ) => ( - - - Selected tab: { label } - - - ) ) } - - ), - }, -}; +// const tabWithIconsData = [ +// { +// value: 'tab1', +// label: 'Tab one', +// icon: WordpressIcon, +// }, +// { +// value: 'tab2', +// label: 'Tab two', +// icon: LinkIcon, +// }, +// { +// value: 'tab3', +// label: 'Tab three', +// icon: MoreIcon, +// }, +// ]; + +// TODO: tooltip still not added to package +// export const WithTabIconsAndTooltips: StoryObj< typeof Tabs.Root > = { +// args: { +// ...Default.args, +// children: ( +// <> +// +// { tabWithIconsData.map( +// ( { value, label, icon: Icon } ) => ( +// +// } +// > +// { /* TODO: potentially refactor with new Icon component */ } +// +// +// +// { label } +// +// +// ) +// ) } +// +// { tabWithIconsData.map( ( { value, label } ) => ( +// +// +// Selected tab: { label } +// +// +// ) ) } +// +// ), +// }, +// }; export const WithPanelsAlwaysMounted: StoryObj< typeof Tabs.Root > = { args: { diff --git a/packages/ui/src/tabs/tab.tsx b/packages/ui/src/tabs/tab.tsx index b0fd2a2a0735e0..d63ec418cb141f 100644 --- a/packages/ui/src/tabs/tab.tsx +++ b/packages/ui/src/tabs/tab.tsx @@ -1,18 +1,7 @@ -/** - * External dependencies - */ -import { forwardRef, cloneElement } from 'react'; +import { cloneElement, forwardRef } from '@wordpress/element'; import clsx from 'clsx'; import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; - -/** - * WordPress dependencies - */ import { chevronRight } from '@wordpress/icons'; - -/** - * Internal dependencies - */ import styles from './style.module.css'; import type { TabProps } from './types'; diff --git a/packages/ui/src/tabs/test/index.tsx b/packages/ui/src/tabs/test/index.tsx index 046a079da87407..aa00fd612ce91f 100644 --- a/packages/ui/src/tabs/test/index.tsx +++ b/packages/ui/src/tabs/test/index.tsx @@ -1,14 +1,7 @@ -/** - * External dependencies - */ -import React, { useEffect, useState } from 'react'; import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { DirectionProvider } from '@base-ui/react/direction-provider'; - -/** - * Internal dependencies - */ +import { useEffect, useState } from '@wordpress/element'; import { Tabs } from '../..'; import type { TabRootProps } from '../types'; diff --git a/packages/ui/src/tabs/types.ts b/packages/ui/src/tabs/types.ts index 37fe8e16f2a511..5914bcbebfcced 100644 --- a/packages/ui/src/tabs/types.ts +++ b/packages/ui/src/tabs/types.ts @@ -1,6 +1,3 @@ -/** - * External dependencies - */ import type { Tabs } from '@base-ui/react/tabs'; export type TabRootProps = Omit< Tabs.Root.Props, 'className' > & { From 78f1740701140ccd704a04a2e806cf03d09ca014 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 13:51:28 +0100 Subject: [PATCH 03/38] Add export --- packages/ui/src/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index dc886022898c19..64c8c05f34ec93 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -5,5 +5,6 @@ export * from './form/primitives'; export * from './icon'; export * from './icon-button'; export * from './stack'; +export * as Tabs from './tabs'; export * as Tooltip from './tooltip'; export * from './visually-hidden'; From b18af2686585728cd950c1a117022b073fa085e5 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 14:16:28 +0100 Subject: [PATCH 04/38] Adjust --wpds-color-stroke-interactive-neutral-strong --- packages/theme/src/prebuilt/css/design-tokens.css | 2 +- packages/theme/src/prebuilt/ts/color-tokens.ts | 6 ++++-- packages/theme/tokens/color.json | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/theme/src/prebuilt/css/design-tokens.css b/packages/theme/src/prebuilt/css/design-tokens.css index f420be9c7d910d..bf35662cb54508 100644 --- a/packages/theme/src/prebuilt/css/design-tokens.css +++ b/packages/theme/src/prebuilt/css/design-tokens.css @@ -86,7 +86,7 @@ --wpds-color-stroke-interactive-neutral: #8a8a8a; /* Accessible stroke color used for interactive neutrally-toned elements with normal emphasis. */ --wpds-color-stroke-interactive-neutral-active: #6c6c6c; /* Accessible stroke color used for interactive neutrally-toned elements with normal emphasis that are hovered, focused, or active. */ --wpds-color-stroke-interactive-neutral-disabled: #d8d8d8; /* Accessible stroke color used for interactive elements with normal emphasis, in their disabled state, regardless of the tone. */ - --wpds-color-stroke-interactive-neutral-strong: #6c6c6c; /* Accessible stroke color used for interactive neutrally-toned elements with strong emphasis. */ + --wpds-color-stroke-interactive-neutral-strong: #2d2d2d; /* Accessible stroke color used for interactive neutrally-toned elements with strong emphasis. */ --wpds-color-stroke-surface-brand: #a3b1d4; /* Decorative stroke color used to define brand-toned surface boundaries with normal emphasis. */ --wpds-color-stroke-surface-brand-strong: #3858e9; /* Decorative stroke color used to define brand-toned surface boundaries with strong emphasis. */ --wpds-color-stroke-surface-error: #daa39b; /* Decorative stroke color used to define error-toned surface boundaries with normal emphasis. */ diff --git a/packages/theme/src/prebuilt/ts/color-tokens.ts b/packages/theme/src/prebuilt/ts/color-tokens.ts index 3f768f8e1c1e6c..90ad68c49ff585 100644 --- a/packages/theme/src/prebuilt/ts/color-tokens.ts +++ b/packages/theme/src/prebuilt/ts/color-tokens.ts @@ -96,7 +96,6 @@ export default { 'bg-stroke4': [ 'bg-thumb-neutral-weak-active', 'stroke-interactive-neutral-active', - 'stroke-interactive-neutral-strong', ], 'bg-stroke2': [ 'bg-thumb-neutral-disabled', @@ -106,7 +105,10 @@ export default { ], 'bg-stroke1': [ 'bg-track-neutral-weak', 'stroke-surface-neutral-weak' ], 'bg-bgFillInverted2': [ 'bg-interactive-neutral-strong-active' ], - 'bg-bgFillInverted1': [ 'bg-interactive-neutral-strong' ], + 'bg-bgFillInverted1': [ + 'bg-interactive-neutral-strong', + 'stroke-interactive-neutral-strong', + ], 'bg-fgFillInverted': [ 'fg-interactive-neutral-strong', 'fg-interactive-neutral-strong-active', diff --git a/packages/theme/tokens/color.json b/packages/theme/tokens/color.json index 21cbdf9c9e2213..c4083cbca0f686 100644 --- a/packages/theme/tokens/color.json +++ b/packages/theme/tokens/color.json @@ -1466,7 +1466,7 @@ "$description": "Accessible stroke color used for interactive elements with normal emphasis, in their disabled state, regardless of the tone." }, "neutral-strong": { - "$value": "{wpds-color.primitive.bg.stroke4}", + "$value": "{wpds-color.primitive.bg.bgFillInverted1}", "$description": "Accessible stroke color used for interactive neutrally-toned elements with strong emphasis." }, "brand": { From 87a35a3e9ca723a76114919f6cd022c6da855946 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 14:18:20 +0100 Subject: [PATCH 05/38] CHANGELOG --- packages/theme/CHANGELOG.md | 1 + packages/ui/CHANGELOG.md | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index d7ab9e27c52fad..194efc15dbb1d4 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -43,6 +43,7 @@ - `--wpds-color-bg-interactive-neutral-strong-disabled` from `#d2d2d2` to `#e2e2e2`. - `--wpds-color-bg-interactive-neutral-weak-disabled` from `#e2e2e2` to `#00000000`. - `--wpds-color-fg-interactive-neutral-strong-disabled` from `#6d6d6d` to `#8a8a8a`. +- Tweaked the value of `--wpds-color-stroke-interactive-neutral-strong` from `#6c6c6c` to `#2d2d2d` ([#74652](https://github.com/WordPress/gutenberg/pull/74652)). ### New Features diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 741b2911aede40..42c545ad728f0c 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -21,3 +21,4 @@ - Add `InputLayout` primitive ([#74313](https://github.com/WordPress/gutenberg/pull/74313)). - Add `Input` primitive ([#74615](https://github.com/WordPress/gutenberg/pull/74615)). - Add `Select` primitive ([#74661](https://github.com/WordPress/gutenberg/pull/74661)). +- Add `Tabs` primitive ([#74652](https://github.com/WordPress/gutenberg/pull/74652)). From 5b4471733cf67a3eac2eeaf34cc1f614cdb513dc Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 14:36:36 +0100 Subject: [PATCH 06/38] Fix stylelint errors --- packages/ui/src/tabs/style.module.css | 127 +++++++++++--------------- 1 file changed, 55 insertions(+), 72 deletions(-) diff --git a/packages/ui/src/tabs/style.module.css b/packages/ui/src/tabs/style.module.css index 2a44cf76d3c7cd..f3bf8c91718e14 100644 --- a/packages/ui/src/tabs/style.module.css +++ b/packages/ui/src/tabs/style.module.css @@ -4,17 +4,13 @@ .tablist { display: flex; align-items: stretch; - overflow-x: auto; - - &[data-orientation="horizontal"] { - width: fit-content; - } + overflow-inline: auto; --direction-factor: 1; --direction-start: left; --direction-end: right; - &:dir( rtl ) { + &:dir(rtl) { --direction-factor: -1; --direction-start: right; --direction-end: left; @@ -24,30 +20,23 @@ &[data-orientation="horizontal"] { --fade-width: 4rem; - --fade-gradient-base: transparent 0%, #000 var( --fade-width ); - --fade-gradient-composed: var( --fade-gradient-base ), #000 60%, - transparent 50%; + --fade-gradient-base: transparent 0%, #000 var(--fade-width); + --fade-gradient-composed: var(--fade-gradient-base), #000 60%, transparent 50%; + + width: fit-content; &.is-overflowing-first { - mask-image: linear-gradient( - to var( --direction-end ), - var( --fade-gradient-base ) - ); + mask-image: linear-gradient(to var(--direction-end), var(--fade-gradient-base)); } &.is-overflowing-last { - mask-image: linear-gradient( - to var( --direction-start ), - var( --fade-gradient-base ) - ); + mask-image: linear-gradient(to var(--direction-start), var(--fade-gradient-base)); } &.is-overflowing-first.is-overflowing-last { - mask-image: linear-gradient( - to right, - var( --fade-gradient-composed ) - ), - linear-gradient( to left, var( --fade-gradient-composed ) ); + mask-image: + linear-gradient(to right, var(--fade-gradient-composed)), + linear-gradient(to left, var(--fade-gradient-composed)); } &.has-compact-density { @@ -62,7 +51,11 @@ .indicator { @media not ( prefers-reduced-motion ) { - transition-property: translate, width, height, border-radius, + transition-property: + translate, + width, + height, + border-radius, border-block; transition-duration: 0.2s; transition-timing-function: ease-out; @@ -77,35 +70,34 @@ &[data-orientation="horizontal"] { z-index: 1; + /* stylelint-disable-next-line plugin/use-logical-properties-and-values -- Physical properties are necessary for the indicator to work as expected. */ left: 0; bottom: 0; - height: var( --wpds-border-width-interactive-focus ); + height: var(--wpds-border-width-interactive-focus); - width: var( --active-tab-width ); - translate: var( --active-tab-left ) 0; - background-color: var( - --wpds-color-stroke-interactive-neutral-strong - ); + width: var(--active-tab-width); + translate: var(--active-tab-left) 0; + background-color: var(--wpds-color-stroke-interactive-neutral-strong); } &[data-orientation="vertical"] { z-index: 0; - border-radius: var( --wpds-border-radius-surface-sm ); + border-radius: var(--wpds-border-radius-surface-sm); top: 0; + /* stylelint-disable-next-line plugin/use-logical-properties-and-values -- Physical properties are necessary for the indicator to work as expected. */ left: 50%; width: 100%; - height: var( --active-tab-height ); - translate: -50% var( --active-tab-top ); - background-color: var( - --wpds-color-bg-interactive-neutral-weak-active - ); + height: var(--active-tab-height); + translate: -50% var(--active-tab-top); + background-color: var(--wpds-color-bg-interactive-neutral-weak-active); } - .tablist[data-select-on-move="true"]:has( :focus-visible ) - &[data-orientation="vertical"] { + .tablist[data-select-on-move="true"]:has(:focus-visible) + &[data-orientation="vertical"] { box-sizing: border-box; - border: var( --wpds-border-width-interactive-focus ) solid - var( --wpds-color-stroke-focus-brand ); + border: + var(--wpds-border-width-interactive-focus) solid + var(--wpds-color-stroke-focus-brand); } } @@ -137,19 +129,19 @@ line-height: 1.2; font-weight: 400; - color: var( --wpds-color-fg-interactive-neutral ); + color: var(--wpds-color-fg-interactive-neutral); &[data-disabled] { cursor: default; - color: var( --wpds-color-fg-interactive-neutral-disabled ); + color: var(--wpds-color-fg-interactive-neutral-disabled); @media ( forced-colors: active ) { color: GrayText; } } - &:not( [data-disabled] ):is( :hover, :focus-visible ) { - color: var( --wpds-color-fg-interactive-neutral-active ); + &:not([data-disabled]):is(:hover, :focus-visible) { + color: var(--wpds-color-fg-interactive-neutral-active); } /* Focus indicator. */ @@ -159,9 +151,10 @@ pointer-events: none; /* Outline works for Windows high contrast mode as well. */ - outline: var( --wpds-border-width-interactive-focus ) solid - var( --wpds-color-stroke-focus-brand ); - border-radius: var( --wpds-border-radius-surface-sm ); + outline: + var(--wpds-border-width-interactive-focus) solid + var(--wpds-color-stroke-focus-brand); + border-radius: var(--wpds-border-radius-surface-sm); /* Animation */ opacity: 0; @@ -176,19 +169,13 @@ } [data-orientation="horizontal"] & { - padding-inline: calc( - 4 * var( --wpds-dimension-base ) - ); /* TODO: Use or create new control/interactive padding token */ - height: calc( - 12 * var( --wpds-dimension-base ) - ); /* TODO: Can we eliminate or hard-code? */ + padding-inline: calc(4 * var(--wpds-dimension-base)); /* TODO: Use or create new control/interactive padding token */ + height: calc(12 * var(--wpds-dimension-base)); /* TODO: Can we eliminate or hard-code? */ scroll-margin: 24px; &::after { content: ""; - inset: calc( - 3 * var( --wpds-dimension-base ) - ); /* TODO: Use or create new control/interactive padding token */ + inset: calc(3 * var(--wpds-dimension-base)); /* TODO: Use or create new control/interactive padding token */ } } @@ -205,18 +192,15 @@ } [data-orientation="vertical"] & { - padding: calc( 2 * var( --wpds-dimension-base ) ) - calc( 3 * var( --wpds-dimension-base ) ); /* TODO: Use or create new control/interactive padding token */ - min-height: calc( - 10 * var( --wpds-dimension-base ) - ); /* TODO: Can we eliminate or hard-code? */ + padding: + calc(2 * var(--wpds-dimension-base)) + calc(3 * var(--wpds-dimension-base)); /* TODO: Use or create new control/interactive padding token */ + min-height: calc(10 * var(--wpds-dimension-base)); /* TODO: Can we eliminate or hard-code? */ } [data-orientation="vertical"][data-select-on-move="false"] &::after { content: ""; - inset: var( - --wpds-border-width-interactive-focus - ); /* TODO: Use or create new control/interactive padding token */ + inset: var(--wpds-border-width-interactive-focus); /* TODO: Use or create new control/interactive padding token */ } } @@ -239,16 +223,14 @@ fill: currentColor; height: 24px; flex-shrink: 0; - margin-inline-end: calc( - var( --wpds-dimension-base ) * -1 - ); /* TODO: Use or create new control/interactive padding token */ + margin-inline-end: calc(var(--wpds-dimension-base) * -1); /* TODO: Use or create new control/interactive padding token */ [data-orientation="horizontal"] & { display: none; } opacity: 0; - [role="tab"]:is( [aria-selected="true"], :focus-visible, :hover ) & { + [role="tab"]:is([aria-selected="true"], :focus-visible, :hover) & { opacity: 1; } @@ -259,13 +241,13 @@ */ @media not ( prefers-reduced-motion ) { [data-select-on-move="true"] - [role="tab"]:is( [aria-selected="true"] ) - & { + [role="tab"]:is([aria-selected="true"]) + & { transition: opacity 0.15s 0.15s linear; } } - &:dir( rtl ) { + &:dir(rtl) { rotate: 180deg; } } @@ -277,8 +259,9 @@ } &:focus-visible { - box-shadow: 0 0 0 var( --wpds-border-width-interactive-focus ) - var( --wpds-color-stroke-focus-brand ); + box-shadow: + 0 0 0 var(--wpds-border-width-interactive-focus) + var(--wpds-color-stroke-focus-brand); /* Windows high contrast mode. */ outline: 2px solid transparent; From e86e69740e0943446f6fdffc54319a4801288de4 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 15:22:35 +0100 Subject: [PATCH 07/38] Add @wordpress/compose to the UI package --- packages/ui/package.json | 1 + packages/ui/tsconfig.json | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/ui/package.json b/packages/ui/package.json index ca22786a95ccd5..71a6018176dc78 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -45,6 +45,7 @@ "dependencies": { "@base-ui/react": "^1.0.0", "@wordpress/a11y": "file:../a11y", + "@wordpress/compose": "file:../compose", "@wordpress/element": "file:../element", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", diff --git a/packages/ui/tsconfig.json b/packages/ui/tsconfig.json index e081a8873e6099..81f7e58d2d1619 100644 --- a/packages/ui/tsconfig.json +++ b/packages/ui/tsconfig.json @@ -6,6 +6,7 @@ }, "references": [ { "path": "../a11y" }, + { "path": "../compose" }, { "path": "../element" }, { "path": "../i18n" }, { "path": "../icons" }, From c2c2771f5994268ea4fa9973db82916447ed3204 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 15:24:37 +0100 Subject: [PATCH 08/38] Unify tests, fix lint errors --- packages/ui/src/tabs/test/index.test.tsx | 2302 +++++++++++++++++++++- packages/ui/src/tabs/test/index.tsx | 2250 --------------------- 2 files changed, 2271 insertions(+), 2281 deletions(-) delete mode 100644 packages/ui/src/tabs/test/index.tsx diff --git a/packages/ui/src/tabs/test/index.test.tsx b/packages/ui/src/tabs/test/index.test.tsx index be926db2eb5188..3631e0c99a494c 100644 --- a/packages/ui/src/tabs/test/index.test.tsx +++ b/packages/ui/src/tabs/test/index.test.tsx @@ -1,39 +1,2279 @@ -/** - * External dependencies - */ -import { render } from '@testing-library/react'; -import { createRef } from 'react'; +/* eslint-disable jest/no-conditional-expect */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { DirectionProvider } from '@base-ui/react/direction-provider'; +import { useEffect, useState, createRef } from '@wordpress/element'; +import { Tabs } from '../..'; +import type { TabRootProps } from '../types'; -/** - * Internal dependencies - */ -import * as Tabs from '../index'; +type Tab = { + value: string; + title: string; + content: React.ReactNode; + tab: { + className?: string; + disabled?: boolean; + }; + tabpanel?: { + focusable?: boolean; + }; +}; -describe( 'Tabs', () => { - it( 'forwards ref', () => { - const rootRef = createRef< HTMLDivElement >(); - const listRef = createRef< HTMLDivElement >(); - const tabRef = createRef< HTMLButtonElement >(); - const panelRef = createRef< HTMLDivElement >(); - - render( - - - - Tab 1 +const TABS: Tab[] = [ + { + value: 'alpha', + title: 'Alpha', + content: 'Selected tab: Alpha', + tab: { className: 'alpha-class' }, + }, + { + value: 'beta', + title: 'Beta', + content: 'Selected tab: Beta', + tab: { className: 'beta-class' }, + }, + { + value: 'gamma', + title: 'Gamma', + content: 'Selected tab: Gamma', + tab: { className: 'gamma-class' }, + }, +]; + +const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => + tabObj.value === 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj +); + +const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) => + tabObj.value === 'beta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj +); + +const TABS_WITH_DELTA: Tab[] = [ + ...TABS, + { + value: 'delta', + title: 'Delta', + content: 'Selected tab: Delta', + tab: { className: 'delta-class' }, + }, +]; + +const UncontrolledTabs = ( { + tabs, + selectOnMove, + ...props +}: Omit< TabRootProps, 'children' | 'tabs' > & { + tabs: Tab[]; + selectOnMove?: boolean; +} ) => { + return ( + + + { tabs.map( ( tabObj, index ) => ( + + { tabObj.title } + + ) ) } + + { tabs.map( ( tabObj, index ) => ( + + { tabObj.content } + + ) ) } + + ); +}; + +const ControlledTabs = ( { + tabs, + selectOnMove, + ...props +}: Omit< TabRootProps, 'children' | 'tabs' > & { + tabs: Tab[]; + selectOnMove?: boolean; +} ) => { + const [ value, setValue ] = useState( props.value ?? null ); + + useEffect( () => { + setValue( props.value ?? null ); + }, [ props.value ] ); + + return ( + { + setValue( selectedId ); + props.onValueChange?.( selectedId, event ); + } } + > + + { tabs.map( ( tabObj, index ) => ( + + { tabObj.title } - Tab 2 - - - Panel 1 content + ) ) } + + { tabs.map( ( tabObj, index ) => ( + + { tabObj.content } - Panel 2 content - + ) ) } + + ); +}; + +let originalGetClientRects: typeof window.HTMLElement.prototype.getClientRects; +let originalScrollTo: typeof Element.prototype.scrollTo; + +async function waitForComponentToBeInitializedWithSelectedTab( + selectedTabName: string | undefined +) { + if ( ! selectedTabName ) { + // Wait for the tablist to be tabbable as a mean to know + // that tabs has finished initializing. + await waitFor( () => + expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( + 'tabindex', + expect.stringMatching( /^(0|-1)$/ ) + ) + ); + // No initially selected tabs or tabpanels. + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() ); + await waitFor( () => + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument() + ); + } else { + // Waiting for a tab to be selected is a sign that the component + // has fully initialized. + expect( + await screen.findByRole( 'tab', { + selected: true, + name: selectedTabName, + } ) + ).toBeVisible(); + // The corresponding tabpanel is also shown. + expect( + screen.getByRole( 'tabpanel', { + name: selectedTabName, + } ) + ).toBeVisible(); + } +} + +describe( 'Tabs', () => { + beforeAll( () => { + originalGetClientRects = window.HTMLElement.prototype.getClientRects; + // Mocking `getClientRects()` is necessary to pass a check performed by + // the `focus.tabbable.find()` and by the `focus.focusable.find()` functions + // from the `@wordpress/dom` package. + // @ts-expect-error We're not trying to comply to the DOM spec, only mocking + window.HTMLElement.prototype.getClientRects = function () { + return [ 'trick-jsdom-into-having-size-for-element-rect' ]; + }; + + // Mock scrollTo since it's not available in JSDOM + originalScrollTo = Element.prototype.scrollTo; + Element.prototype.scrollTo = jest.fn(); + } ); + + afterAll( () => { + window.HTMLElement.prototype.getClientRects = originalGetClientRects; + Element.prototype.scrollTo = originalScrollTo; + } ); + + describe( 'Adherence to spec and basic behavior', () => { + it( 'should apply the correct roles, semantics and attributes', async () => { + render( + + + One + Two + Three + + First panel + Second panel + Third panel + + ); + + await waitForComponentToBeInitializedWithSelectedTab( 'One' ); + + const tabList = screen.getByRole( 'tablist' ); + const allTabs = screen.getAllByRole( 'tab' ); + const allTabpanels = screen.getAllByRole( 'tabpanel' ); + + expect( tabList ).toBeVisible(); + // Since 'horizontal' is the default orientation, no need to set it. + expect( tabList ).not.toHaveAttribute( 'aria-orientation' ); + + expect( allTabs ).toHaveLength( TABS.length ); + + // Only 1 tab panel is accessible — the one associated with the + // selected tab. The selected `tab` aria-controls the active + // `tabpanel`, which is `aria-labelledby` the selected `tab`. + expect( allTabpanels ).toHaveLength( 1 ); + + expect( allTabpanels[ 0 ] ).toBeVisible(); + + expect( allTabs[ 0 ] ).toHaveAttribute( + 'aria-controls', + allTabpanels[ 0 ].getAttribute( 'id' ) + ); + expect( allTabpanels[ 0 ] ).toHaveAttribute( + 'aria-labelledby', + allTabs[ 0 ].getAttribute( 'id' ) + ); + } ); + + it( 'should associate each `tab` with the correct `tabpanel`, even if they are not rendered in the same order', async () => { + const TABS_WITH_DELTA_REVERSED = [ ...TABS_WITH_DELTA ].reverse(); + + const user = userEvent.setup(); + + render( + + + { TABS_WITH_DELTA.map( ( tabObj, index ) => ( + + { tabObj.title } + + ) ) } + + { TABS_WITH_DELTA_REVERSED.map( ( tabObj, index ) => ( + + { tabObj.content } + + ) ) } + + ); + + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // Select Beta, make sure the correct tabpanel is rendered + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + // Select Gamma, make sure the correct tabpanel is rendered + await user.click( screen.getByRole( 'tab', { name: 'Gamma' } ) ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + // Select Delta, make sure the correct tabpanel is rendered + await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Delta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Delta', + } ) + ).toBeVisible(); + } ); + + it( "should apply the tab's `className` to the tab button", async () => { + render( ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveClass( 'alpha-class' ); + expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveClass( + 'beta-class' + ); + expect( screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveClass( + 'gamma-class' + ); + } ); + + it( 'should forward refs', () => { + const rootRef = createRef< HTMLDivElement >(); + const listRef = createRef< HTMLDivElement >(); + const tabRef = createRef< HTMLButtonElement >(); + const panelRef = createRef< HTMLDivElement >(); + + render( + + + + Tab 1 + + Tab 2 + + + Panel 1 content + + Panel 2 content + + ); + + expect( rootRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( listRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( tabRef.current ).toBeInstanceOf( HTMLButtonElement ); + expect( panelRef.current ).toBeInstanceOf( HTMLDivElement ); + } ); + } ); + + describe( 'pointer interactions', () => { + it( 'should select a tab when clicked', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + render( + + ); + + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // Click on Beta, make sure beta is the selected tab + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + + // Click on Alpha, make sure alpha is the selected tab + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'alpha', + expect.anything() + ); + } ); + + it( 'should not select a disabled tab when clicked', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // Clicking on Beta does not result in beta being selected + // because the tab is disabled. + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); + } ); + } ); + + describe( 'initial tab selection', () => { + describe( 'when a selected tab id is not specified', () => { + describe( 'when left `undefined` [Uncontrolled]', () => { + it( 'should choose the first tab as selected', async () => { + const user = userEvent.setup(); + + render( ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + + // Press tab. The selected tab (alpha) received focus. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // TODO: check that `onValueChange` fired + // once https://github.com/mui/base-ui/issues/2097 is fixed + } ); + + it( 'should choose the first non-disabled tab if the first tab is disabled', async () => { + const user = userEvent.setup(); + + render( + + ); + + // Beta is automatically selected as the selected tab, since alpha is + // disabled. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus. The corresponding + // tabpanel is shown. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + + // TODO: check that `onValueChange` fired + // once https://github.com/mui/base-ui/issues/2097 is fixed + } ); + } ); + describe( 'when `null` [Controlled]', () => { + it( 'should not have a selected tab nor show any tabpanels, make the tablist tabbable and still allow selecting tabs', async () => { + const user = userEvent.setup(); + + render( ); + + // No initially selected tabs or tabpanels. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + + // Press tab to focus and select the first tab (alpha) and + // show the related tabpanel. + await user.keyboard( '{Tab}' ); + await user.keyboard( '{Enter}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } ); + } ); + } ); + + describe( 'when a selected tab id is specified', () => { + describe( 'through the `defaultValue` prop [Uncontrolled]', () => { + it( 'should select the initial tab matching the `defaultValue` prop', async () => { + const user = userEvent.setup(); + + render( + + ); + + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus. The corresponding + // tabpanel is shown. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + + it( 'should select the initial tab matching the `defaultValue` prop even if the tab is disabled', async () => { + const user = userEvent.setup(); + render( + + ); + + // Beta is automatically selected as the selected tab despite being + // disabled, respecting the `defaultValue` prop. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus, since it is + // accessible despite being disabled. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + + it( 'should select the first tab and allow tabbing to it when `defaultValue` prop does not match any known tab', async () => { + const user = userEvent.setup(); + + render( + + ); + + // No initially selected tabs or tabpanels, since the `defaultValue` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + + // Press tab. The first tab receives focus, but it's + // not selected. + await user.keyboard( '{Tab}' ); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + await user.keyboard( '{Enter}' ); + expect( + screen.queryByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } ); + + it( 'should select the first non-disabled tab and allow tabbing to it when `defaultValue` prop does not match any known tab', async () => { + const user = userEvent.setup(); + render( + + ); + + // No initially selected tabs or tabpanels, since the `defaultValue` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The first non-disabled tab receives focus and is selected. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } ); + + it( 'should ignore any changes to the `defaultValue` prop after the first render', async () => { + const mockOnValueChange = jest.fn(); + const consoleErrorSpy = jest + .spyOn( console, 'error' ) + .mockImplementation( () => {} ); + + const { rerender } = render( + + ); + + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Changing the defaultValue prop to gamma should not have any effect. + rerender( + + ); + + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + + expect( consoleErrorSpy ).toHaveBeenCalled(); + expect( consoleErrorSpy ).toHaveBeenCalledWith( + expect.stringContaining( + 'changing the default value state' + ) + ); + + consoleErrorSpy.mockRestore(); + } ); + } ); + + describe( 'through the `value` prop [Controlled]', () => { + describe( 'when the `value` matches an existing tab', () => { + it( 'should choose the initial tab matching the `value`', async () => { + const user = userEvent.setup(); + + render( ); + + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus, since it is + // accessible despite being disabled. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + + it( 'should choose the initial tab matching the `value` even if a `defaultValue` is passed', async () => { + const user = userEvent.setup(); + + render( + + ); + + // Gamma is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Gamma' + ); + + // Press tab. The selected tab (gamma) received focus. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + } ); + + it( 'should choose the initial tab matching the `value` even if the tab is disabled', async () => { + const user = userEvent.setup(); + + render( + + ); + + // Beta is the initially selected tab + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Press tab. The selected tab (beta) received focus, since it is + // accessible despite being disabled. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + } ); + + describe( "when the `value` doesn't match an existing tab", () => { + it( 'should not have a selected tab nor show any tabpanels, but allow tabbing to the first tab', async () => { + const user = userEvent.setup(); + + render( + + ); + + // No initially selected tabs or tabpanels, since the `value` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + + // Press tab. The first tab receives focus and gets selected. + await user.keyboard( '{Tab}' ); + await user.keyboard( '{Enter}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } ); + + it( 'should not have a selected tab nor show any tabpanels, but allow tabbing to the first tab even when disabled', async () => { + const user = userEvent.setup(); + + render( + + ); + + // No initially selected tabs or tabpanels, since the `value` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + + // Press tab. The first tab receives focus, but it's + // not selected since it's disabled. + await user.keyboard( '{Tab}' ); + expect( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + await waitFor( () => + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument() + ); + + // Press right arrow to select the next tab (beta) and + // show the related tabpanel. + await user.keyboard( '{ArrowRight}' ); + await user.keyboard( '{Enter}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } ); + } ); + } ); + } ); + } ); + + describe( 'keyboard interactions', () => { + describe.each( [ + [ 'Uncontrolled', UncontrolledTabs ], + [ 'Controlled', ControlledTabs ], + ] )( '[`%s`]', ( _mode, Component ) => { + it( 'should handle the tablist as one tab stop', async () => { + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // Press tab. The selected tab (alpha) received focus. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // By default the tabpanel should receive focus + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toHaveFocus(); + } ); + + it( 'should not focus the tabpanel container when its `focusable` property is set to `false`', async () => { + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + tabObj.value === 'alpha' + ? { + ...tabObj, + content: ( + <> + Selected Tab: Alpha + + + ), + tabpanel: { focusable: false }, + } + : tabObj + ) } + { ...valueProps } + /> + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // In this case, the tabpanel container is skipped and focus is + // moved directly to its contents + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'button', { + name: 'Alpha Button', + } ) + ).toHaveFocus(); + } ); + + it( 'should select tabs in the tablist when using the left and right arrow keys when automatic tab activation is enabled', async () => { + const mockOnValueChange = jest.fn(); + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Press the right arrow key to select the beta tab + await user.keyboard( '{ArrowRight}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + + // Press the right arrow key to select the gamma tab + await user.keyboard( '{ArrowRight}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'gamma', + expect.anything() + ); + + // Press the left arrow key to select the beta tab + await user.keyboard( '{ArrowLeft}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 3 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + } ); + + it( 'should not automatically select tabs in the tablist when pressing the left and right arrow keys by default (manual tab activation)', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Press the right arrow key to move focus to the beta tab, + // but without selecting it + await user.keyboard( '{ArrowRight}' ); + + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); + + // Press the space key to click the beta tab, and select it. + // The same should be true with any other mean of clicking the tab button + // (ie. mouse click, enter key). + await user.keyboard( '{ }' ); + + await waitFor( () => + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus() + ); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + } ); + + it( 'should not select tabs in the tablist when using the up and down arrow keys, unless the `orientation` prop is set to `vertical`', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + const { rerender } = render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Press the up arrow key, but the focused/selected tab does not change. + await user.keyboard( '{ArrowUp}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); + + // Press the down arrow key, but the focused/selected tab does not change. + await user.keyboard( '{ArrowDown}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); + + // Change the orientation to "vertical" and rerender the component. + rerender( + + ); + + // Pressing the down arrow key now selects the next tab (beta). + await user.keyboard( '{ArrowDown}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + + // Pressing the up arrow key now selects the previous tab (alpha). + await user.keyboard( '{ArrowUp}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'alpha', + expect.anything() + ); + } ); + + it( 'should loop tab focus at the end of the tablist when using arrow keys', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Press the left arrow key to loop around and select the gamma tab + await user.keyboard( '{ArrowLeft}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'gamma', + expect.anything() + ); + + // Press the right arrow key to loop around and select the alpha tab + await user.keyboard( '{ArrowRight}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'alpha', + expect.anything() + ); + } ); + + it( 'should swap the left and right arrow keys when selecting tabs if the writing direction is set to RTL', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Press the left arrow key to select the beta tab + await user.keyboard( '{ArrowLeft}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + + // Press the left arrow key to select the gamma tab + await user.keyboard( '{ArrowLeft}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'gamma', + expect.anything() + ); + + // Press the right arrow key to select the beta tab + await user.keyboard( '{ArrowRight}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 3 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + } ); + + it( 'should focus tabs in the tablist even if disabled', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const valueProps = + _mode === 'Uncontrolled' + ? { defaultValue: 'alpha' } + : { value: 'alpha' }; + + render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Focus the tablist (and the selected tab, alpha) + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '{Tab}' ); + expect( + await screen.findByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + + // Pressing the right arrow key moves focus to the beta tab, but alpha + // remains the selected tab because beta is disabled. + await user.keyboard( '{ArrowRight}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); + + // Press the right arrow key to select the gamma tab + await user.keyboard( '{ArrowRight}' ); + await user.keyboard( '{Enter}' ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'gamma', + expect.anything() + ); + } ); + } ); + + describe( 'When `selectedId` is changed by the controlling component [Controlled]', () => { + describe.each( [ true, false ] )( + 'and automatic tab activation is %s', + ( selectOnMove ) => { + it( 'should continue to handle arrow key navigation properly', async () => { + const user = userEvent.setup(); + + const { rerender } = render( + + ); + + // Beta is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Tab key should focus the currently first tab (if manual activation mode), + // or the currently selected tab (if automatic activation mode). + await user.keyboard( '{Tab}' ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + + rerender( + + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) + ).toHaveFocus(); + + // Arrow left should move focus to the previous tab. + await user.keyboard( '{ArrowLeft}' ); + + await waitFor( () => + expect( + screen.getByRole( 'tab', { + selected: selectOnMove, + name: 'Alpha', + } ) + ).toHaveFocus() + ); + } ); + + it( 'should focus the correct tab when tabbing out and back into the tablist', async () => { + const user = userEvent.setup(); + + const { rerender } = render( + <> + + + + ); + + // Beta is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + // Tab key should focus the currently selected tab, which is Beta. + await user.keyboard( '{Tab}' ); + await user.keyboard( '{Tab}' ); + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + + // Change the selected tab to gamma via a controlled update. + rerender( + <> + + + + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tab', { + selected: false, + name: 'Beta', + } ) + ).toHaveFocus(); + + // Press shift+tab, move focus to the button before Tabs + await user.keyboard( '{Shift>}{Tab}{/Shift}' ); + expect( + screen.getByRole( 'button', { name: 'Focus me' } ) + ).toHaveFocus(); + + // Press tab, move focus back to the tablist + await user.keyboard( '{Tab}' ); + + expect( + screen.getByRole( 'tab', { + name: 'Beta', + } ) + ).toHaveFocus(); + } ); + } + ); + } ); + } ); + + describe( 'miscellaneous runtime changes', () => { + describe( 'removing a tab', () => { + describe( 'with no explicitly set initial tab', () => { + it( 'should not select a new tab when the selected tab is removed', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const { rerender } = render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Select gamma + await user.click( + screen.getByRole( 'tab', { name: 'Gamma' } ) + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'gamma', + expect.anything() + ); + + // Remove gamma + rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + + // Falls back to the first tab. + expect( + screen.getByRole( 'tab', { + name: 'Alpha', + selected: true, + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe.each( [ + [ 'defaultValue', 'Uncontrolled', UncontrolledTabs ], + [ 'value', 'Controlled', ControlledTabs ], + ] )( + 'when using the `%s` prop [%s]', + ( propName, mode, Component ) => { + it( 'should handle the selected tab being removed', async () => { + const mockOnValueChange = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'gamma', + onValueChange: mockOnValueChange, + }; + + const { rerender } = render( + + ); + + // Gamma is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Gamma' + ); + + // Remove gamma + rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + 2 + ); + + if ( mode === 'Uncontrolled' ) { + // Falls back to the first tab. + expect( + screen.getByRole( 'tab', { + name: 'Alpha', + selected: true, + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // No tab should be selected i.e. it doesn't fall back to first tab. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } + + // Re-add gamma. + rerender( ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + TABS.length + ); + + if ( mode === 'Uncontrolled' ) { + // First tab stays selected. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // Gamma becomes selected again. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Gamma', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Gamma', + } ) + ).toBeVisible(); + } + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + } ); + + it( `should not fall back to the tab matching the \`${ propName }\` prop when a different selected tab is removed`, async () => { + const mockOnValueChange = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'gamma', + onValueChange: mockOnValueChange, + }; + + const user = userEvent.setup(); + + const { rerender } = render( + + ); + + // Gamma is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Gamma' + ); + + // Select alpha + await user.click( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'alpha', + expect.anything() + ); + + // Remove alpha + rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + 2 + ); + + if ( mode === 'Uncontrolled' ) { + // Falls back to the first available tab. + expect( + screen.getByRole( 'tab', { + name: 'Beta', + selected: true, + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // No tab should be selected i.e. it doesn't fall back to gamma, + // even if it matches the `defaultValue` prop. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } + + // Re-add alpha. Alpha becomes selected again. + rerender( ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( + TABS.length + ); + + if ( mode === 'Uncontrolled' ) { + // Beta stays selected. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + } ); + } + ); + } ); + + describe( 'adding a tab', () => { + describe.each( [ + [ 'defaultValue', 'Uncontrolled', UncontrolledTabs ], + [ 'value', 'Controlled', ControlledTabs ], + ] )( + 'when using the `%s` prop [%s]', + ( propName, mode, Component ) => { + it( `should select a newly added tab if it matches the \`${ propName }\` prop`, async () => { + const mockOnValueChange = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'delta', + onValueChange: mockOnValueChange, + }; + + const { rerender } = render( + + ); + + if ( mode === 'Uncontrolled' ) { + // Falls back to the first tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + } + + if ( mode === 'Controlled' ) { + // No initially selected tabs or tabpanels, since the `value` + // prop is not matching any known tabs. + await waitForComponentToBeInitializedWithSelectedTab( + undefined + ); + } + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + + // Re-render with delta added. + rerender( + + ); + + if ( mode === 'Uncontrolled' ) { + // Alpha stays selected. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // Delta becomes selected + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Delta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Delta', + } ) + ).toBeVisible(); + } + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + } ); + } + ); + } ); + describe( 'a tab becomes disabled', () => { + describe.each( [ + [ 'defaultValue', 'Uncontrolled', UncontrolledTabs ], + [ 'value', 'Controlled', ControlledTabs ], + ] )( + 'when using the `%s` prop [%s]', + ( propName, mode, Component ) => { + it( `should keep the initial tab matching the \`${ propName }\` prop as selected even if it becomes disabled`, async () => { + const mockOnValueChange = jest.fn(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'beta', + onValueChange: mockOnValueChange, + }; + + const { rerender } = render( + + ); + + // Beta is the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Beta' + ); + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + + // Re-render with beta disabled. + rerender( + + ); + + // Beta continues to be selected and focused, even if it is disabled. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + // Re-enable beta. + rerender( ); + + // Beta continues to be selected and focused. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).not.toHaveBeenCalled(); + } ); + + it( 'should handle the user-selected tab becoming disabled', async () => { + const mockOnValueChange = jest.fn(); + + const user = userEvent.setup(); + + const initialComponentProps = { + tabs: TABS, + [ propName ]: 'alpha', + onValueChange: mockOnValueChange, + }; + + const { rerender } = render( + + ); + + // Alpha is automatically selected as the selected tab. + await waitForComponentToBeInitializedWithSelectedTab( + 'Alpha' + ); + + // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed + // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); + + // Click on beta tab, beta becomes selected. + await user.click( + screen.getByRole( 'tab', { name: 'Beta' } ) + ); + + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + expect( mockOnValueChange ).toHaveBeenLastCalledWith( + 'beta', + expect.anything() + ); + + // Re-render with beta disabled. + rerender( + + ); + + if ( mode === 'Uncontrolled' ) { + // Alpha becomes the selected tab. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // Beta continues to be selected, even if it is disabled. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toHaveFocus(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } + + // Re-enable beta. + rerender( ); + + if ( mode === 'Uncontrolled' ) { + // Alpha stays selected. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Alpha', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Alpha', + } ) + ).toBeVisible(); + } + + if ( mode === 'Controlled' ) { + // Beta continues to be selected and focused. + expect( + screen.getByRole( 'tab', { + selected: true, + name: 'Beta', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'tabpanel', { + name: 'Beta', + } ) + ).toBeVisible(); + } - expect( rootRef.current ).toBeInstanceOf( HTMLDivElement ); - expect( listRef.current ).toBeInstanceOf( HTMLDivElement ); - expect( tabRef.current ).toBeInstanceOf( HTMLButtonElement ); - expect( panelRef.current ).toBeInstanceOf( HTMLDivElement ); + expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); + } ); + } + ); + } ); } ); } ); +/* eslint-enable jest/no-conditional-expect */ diff --git a/packages/ui/src/tabs/test/index.tsx b/packages/ui/src/tabs/test/index.tsx deleted file mode 100644 index aa00fd612ce91f..00000000000000 --- a/packages/ui/src/tabs/test/index.tsx +++ /dev/null @@ -1,2250 +0,0 @@ -import { render, screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import { DirectionProvider } from '@base-ui/react/direction-provider'; -import { useEffect, useState } from '@wordpress/element'; -import { Tabs } from '../..'; -import type { TabRootProps } from '../types'; - -type Tab = { - value: string; - title: string; - content: React.ReactNode; - tab: { - className?: string; - disabled?: boolean; - }; - tabpanel?: { - focusable?: boolean; - }; -}; - -const TABS: Tab[] = [ - { - value: 'alpha', - title: 'Alpha', - content: 'Selected tab: Alpha', - tab: { className: 'alpha-class' }, - }, - { - value: 'beta', - title: 'Beta', - content: 'Selected tab: Beta', - tab: { className: 'beta-class' }, - }, - { - value: 'gamma', - title: 'Gamma', - content: 'Selected tab: Gamma', - tab: { className: 'gamma-class' }, - }, -]; - -const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => - tabObj.value === 'alpha' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj -); - -const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) => - tabObj.value === 'beta' - ? { - ...tabObj, - tab: { - ...tabObj.tab, - disabled: true, - }, - } - : tabObj -); - -const TABS_WITH_DELTA: Tab[] = [ - ...TABS, - { - value: 'delta', - title: 'Delta', - content: 'Selected tab: Delta', - tab: { className: 'delta-class' }, - }, -]; - -const UncontrolledTabs = ( { - tabs, - selectOnMove, - ...props -}: Omit< TabRootProps, 'children' | 'tabs' > & { - tabs: Tab[]; - selectOnMove?: boolean; -} ) => { - return ( - - - { tabs.map( ( tabObj, index ) => ( - - { tabObj.title } - - ) ) } - - { tabs.map( ( tabObj, index ) => ( - - { tabObj.content } - - ) ) } - - ); -}; - -const ControlledTabs = ( { - tabs, - selectOnMove, - ...props -}: Omit< TabRootProps, 'children' | 'tabs' > & { - tabs: Tab[]; - selectOnMove?: boolean; -} ) => { - const [ value, setValue ] = useState( props.value ?? null ); - - useEffect( () => { - setValue( props.value ?? null ); - }, [ props.value ] ); - - return ( - { - setValue( selectedId ); - props.onValueChange?.( selectedId, event ); - } } - > - - { tabs.map( ( tabObj, index ) => ( - - { tabObj.title } - - ) ) } - - { tabs.map( ( tabObj, index ) => ( - - { tabObj.content } - - ) ) } - - ); -}; - -let originalGetClientRects: typeof window.HTMLElement.prototype.getClientRects; -let originalScrollTo: typeof Element.prototype.scrollTo; - -async function waitForComponentToBeInitializedWithSelectedTab( - selectedTabName: string | undefined -) { - if ( ! selectedTabName ) { - // Wait for the tablist to be tabbable as a mean to know - // that tabs has finished initializing. - await waitFor( () => - expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( - 'tabindex', - expect.stringMatching( /^(0|-1)$/ ) - ) - ); - // No initially selected tabs or tabpanels. - await waitFor( () => - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument() - ); - await waitFor( () => - expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument() - ); - } else { - // Waiting for a tab to be selected is a sign that the component - // has fully initialized. - expect( - await screen.findByRole( 'tab', { - selected: true, - name: selectedTabName, - } ) - ).toBeVisible(); - // The corresponding tabpanel is also shown. - expect( - screen.getByRole( 'tabpanel', { - name: selectedTabName, - } ) - ).toBeVisible(); - } -} - -describe( 'Tabs', () => { - beforeAll( () => { - originalGetClientRects = window.HTMLElement.prototype.getClientRects; - // Mocking `getClientRects()` is necessary to pass a check performed by - // the `focus.tabbable.find()` and by the `focus.focusable.find()` functions - // from the `@wordpress/dom` package. - // @ts-expect-error We're not trying to comply to the DOM spec, only mocking - window.HTMLElement.prototype.getClientRects = function () { - return [ 'trick-jsdom-into-having-size-for-element-rect' ]; - }; - - // Mock scrollTo since it's not available in JSDOM - originalScrollTo = Element.prototype.scrollTo; - Element.prototype.scrollTo = jest.fn(); - } ); - - afterAll( () => { - window.HTMLElement.prototype.getClientRects = originalGetClientRects; - Element.prototype.scrollTo = originalScrollTo; - } ); - - describe( 'Adherence to spec and basic behavior', () => { - it( 'should apply the correct roles, semantics and attributes', async () => { - render( - - - One - Two - Three - - First panel - Second panel - Third panel - - ); - - await waitForComponentToBeInitializedWithSelectedTab( 'One' ); - - const tabList = screen.getByRole( 'tablist' ); - const allTabs = screen.getAllByRole( 'tab' ); - const allTabpanels = screen.getAllByRole( 'tabpanel' ); - - expect( tabList ).toBeVisible(); - // Since 'horizontal' is the default orientation, no need to set it. - expect( tabList ).not.toHaveAttribute( 'aria-orientation' ); - - expect( allTabs ).toHaveLength( TABS.length ); - - // Only 1 tab panel is accessible — the one associated with the - // selected tab. The selected `tab` aria-controls the active - // `tabpanel`, which is `aria-labelledby` the selected `tab`. - expect( allTabpanels ).toHaveLength( 1 ); - - expect( allTabpanels[ 0 ] ).toBeVisible(); - - expect( allTabs[ 0 ] ).toHaveAttribute( - 'aria-controls', - allTabpanels[ 0 ].getAttribute( 'id' ) - ); - expect( allTabpanels[ 0 ] ).toHaveAttribute( - 'aria-labelledby', - allTabs[ 0 ].getAttribute( 'id' ) - ); - } ); - - it( 'should associate each `tab` with the correct `tabpanel`, even if they are not rendered in the same order', async () => { - const TABS_WITH_DELTA_REVERSED = [ ...TABS_WITH_DELTA ].reverse(); - - const user = userEvent.setup(); - - render( - - - { TABS_WITH_DELTA.map( ( tabObj, index ) => ( - - { tabObj.title } - - ) ) } - - { TABS_WITH_DELTA_REVERSED.map( ( tabObj, index ) => ( - - { tabObj.content } - - ) ) } - - ); - - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // Select Beta, make sure the correct tabpanel is rendered - await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - // Select Gamma, make sure the correct tabpanel is rendered - await user.click( screen.getByRole( 'tab', { name: 'Gamma' } ) ); - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Gamma', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Gamma', - } ) - ).toBeVisible(); - - // Select Delta, make sure the correct tabpanel is rendered - await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Delta', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Delta', - } ) - ).toBeVisible(); - } ); - - it( "should apply the tab's `className` to the tab button", async () => { - render( ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - expect( - await screen.findByRole( 'tab', { name: 'Alpha' } ) - ).toHaveClass( 'alpha-class' ); - expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveClass( - 'beta-class' - ); - expect( screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveClass( - 'gamma-class' - ); - } ); - } ); - - describe( 'pointer interactions', () => { - it( 'should select a tab when clicked', async () => { - const mockOnValueChange = jest.fn(); - - const user = userEvent.setup(); - - render( - - ); - - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // Click on Beta, make sure beta is the selected tab - await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'beta', - expect.anything() - ); - - // Click on Alpha, make sure alpha is the selected tab - await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'alpha', - expect.anything() - ); - } ); - - it( 'should not select a disabled tab when clicked', async () => { - const mockOnValueChange = jest.fn(); - - const user = userEvent.setup(); - - render( - - ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // Clicking on Beta does not result in beta being selected - // because the tab is disabled. - await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); - } ); - } ); - - describe( 'initial tab selection', () => { - describe( 'when a selected tab id is not specified', () => { - describe( 'when left `undefined` [Uncontrolled]', () => { - it( 'should choose the first tab as selected', async () => { - const user = userEvent.setup(); - - render( ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( - 'Alpha' - ); - - // Press tab. The selected tab (alpha) received focus. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - - // TODO: check that `onValueChange` fired - // once https://github.com/mui/base-ui/issues/2097 is fixed - } ); - - it( 'should choose the first non-disabled tab if the first tab is disabled', async () => { - const user = userEvent.setup(); - - render( - - ); - - // Beta is automatically selected as the selected tab, since alpha is - // disabled. - await waitForComponentToBeInitializedWithSelectedTab( - 'Beta' - ); - - // Press tab. The selected tab (beta) received focus. The corresponding - // tabpanel is shown. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - - // TODO: check that `onValueChange` fired - // once https://github.com/mui/base-ui/issues/2097 is fixed - } ); - } ); - describe( 'when `null` [Controlled]', () => { - it( 'should not have a selected tab nor show any tabpanels, make the tablist tabbable and still allow selecting tabs', async () => { - const user = userEvent.setup(); - - render( ); - - // No initially selected tabs or tabpanels. - await waitForComponentToBeInitializedWithSelectedTab( - undefined - ); - - // Press tab to focus and select the first tab (alpha) and - // show the related tabpanel. - await user.keyboard( '{Tab}' ); - await user.keyboard( '{Enter}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - expect( - await screen.findByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - } ); - } ); - } ); - - describe( 'when a selected tab id is specified', () => { - describe( 'through the `defaultValue` prop [Uncontrolled]', () => { - it( 'should select the initial tab matching the `defaultValue` prop', async () => { - const user = userEvent.setup(); - - render( - - ); - - // Beta is the initially selected tab - await waitForComponentToBeInitializedWithSelectedTab( - 'Beta' - ); - - // Press tab. The selected tab (beta) received focus. The corresponding - // tabpanel is shown. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - } ); - - it( 'should select the initial tab matching the `defaultValue` prop even if the tab is disabled', async () => { - const user = userEvent.setup(); - render( - - ); - - // Beta is automatically selected as the selected tab despite being - // disabled, respecting the `defaultValue` prop. - await waitForComponentToBeInitializedWithSelectedTab( - 'Beta' - ); - - // Press tab. The selected tab (beta) received focus, since it is - // accessible despite being disabled. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - } ); - - it( 'should select the first tab and allow tabbing to it when `defaultValue` prop does not match any known tab', async () => { - const user = userEvent.setup(); - - render( - - ); - - // No initially selected tabs or tabpanels, since the `defaultValue` - // prop is not matching any known tabs. - await waitForComponentToBeInitializedWithSelectedTab( - 'Alpha' - ); - - // Press tab. The first tab receives focus, but it's - // not selected. - await user.keyboard( '{Tab}' ); - expect( - screen.getByRole( 'tab', { name: 'Alpha' } ) - ).toHaveFocus(); - await user.keyboard( '{Enter}' ); - expect( - screen.queryByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toBeVisible(); - expect( - await screen.findByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - } ); - - it( 'should select the first non-disabled tab and allow tabbing to it when `defaultValue` prop does not match any known tab', async () => { - const user = userEvent.setup(); - render( - - ); - - // No initially selected tabs or tabpanels, since the `defaultValue` - // prop is not matching any known tabs. - await waitForComponentToBeInitializedWithSelectedTab( - 'Beta' - ); - - // Press tab. The first non-disabled tab receives focus and is selected. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - expect( - await screen.findByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - } ); - - it( 'should ignore any changes to the `defaultValue` prop after the first render', async () => { - const mockOnValueChange = jest.fn(); - const consoleErrorSpy = jest - .spyOn( console, 'error' ) - .mockImplementation( () => {} ); - - const { rerender } = render( - - ); - - // Beta is the initially selected tab - await waitForComponentToBeInitializedWithSelectedTab( - 'Beta' - ); - - // Changing the defaultValue prop to gamma should not have any effect. - rerender( - - ); - - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).not.toHaveBeenCalled(); - - expect( consoleErrorSpy ).toHaveBeenCalled(); - expect( consoleErrorSpy ).toHaveBeenCalledWith( - expect.stringContaining( - 'changing the default value state' - ) - ); - - consoleErrorSpy.mockRestore(); - } ); - } ); - - describe( 'through the `value` prop [Controlled]', () => { - describe( 'when the `value` matches an existing tab', () => { - it( 'should choose the initial tab matching the `value`', async () => { - const user = userEvent.setup(); - - render( ); - - // Beta is the initially selected tab - await waitForComponentToBeInitializedWithSelectedTab( - 'Beta' - ); - - // Press tab. The selected tab (beta) received focus, since it is - // accessible despite being disabled. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - } ); - - it( 'should choose the initial tab matching the `value` even if a `defaultValue` is passed', async () => { - const user = userEvent.setup(); - - render( - - ); - - // Gamma is the initially selected tab - await waitForComponentToBeInitializedWithSelectedTab( - 'Gamma' - ); - - // Press tab. The selected tab (gamma) received focus. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Gamma', - } ) - ).toHaveFocus(); - } ); - - it( 'should choose the initial tab matching the `value` even if the tab is disabled', async () => { - const user = userEvent.setup(); - - render( - - ); - - // Beta is the initially selected tab - await waitForComponentToBeInitializedWithSelectedTab( - 'Beta' - ); - - // Press tab. The selected tab (beta) received focus, since it is - // accessible despite being disabled. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - } ); - } ); - - describe( "when the `value` doesn't match an existing tab", () => { - it( 'should not have a selected tab nor show any tabpanels, but allow tabbing to the first tab', async () => { - const user = userEvent.setup(); - - render( - - ); - - // No initially selected tabs or tabpanels, since the `value` - // prop is not matching any known tabs. - await waitForComponentToBeInitializedWithSelectedTab( - undefined - ); - - // Press tab. The first tab receives focus and gets selected. - await user.keyboard( '{Tab}' ); - await user.keyboard( '{Enter}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - expect( - await screen.findByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - } ); - - it( 'should not have a selected tab nor show any tabpanels, but allow tabbing to the first tab even when disabled', async () => { - const user = userEvent.setup(); - - render( - - ); - - // No initially selected tabs or tabpanels, since the `value` - // prop is not matching any known tabs. - await waitForComponentToBeInitializedWithSelectedTab( - undefined - ); - - // Press tab. The first tab receives focus, but it's - // not selected since it's disabled. - await user.keyboard( '{Tab}' ); - expect( - screen.getByRole( 'tab', { name: 'Alpha' } ) - ).toHaveFocus(); - await waitFor( () => - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument() - ); - await waitFor( () => - expect( - screen.queryByRole( 'tabpanel' ) - ).not.toBeInTheDocument() - ); - - // Press right arrow to select the next tab (beta) and - // show the related tabpanel. - await user.keyboard( '{ArrowRight}' ); - await user.keyboard( '{Enter}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - expect( - await screen.findByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - } ); - } ); - } ); - } ); - } ); - - describe( 'keyboard interactions', () => { - describe.each( [ - [ 'Uncontrolled', UncontrolledTabs ], - [ 'Controlled', ControlledTabs ], - ] )( '[`%s`]', ( _mode, Component ) => { - it( 'should handle the tablist as one tab stop', async () => { - const user = userEvent.setup(); - - const valueProps = - _mode === 'Uncontrolled' - ? { defaultValue: 'alpha' } - : { value: 'alpha' }; - - render( ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // Press tab. The selected tab (alpha) received focus. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - - // By default the tabpanel should receive focus - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toHaveFocus(); - } ); - - it( 'should not focus the tabpanel container when its `focusable` property is set to `false`', async () => { - const user = userEvent.setup(); - - const valueProps = - _mode === 'Uncontrolled' - ? { defaultValue: 'alpha' } - : { value: 'alpha' }; - - render( - - tabObj.value === 'alpha' - ? { - ...tabObj, - content: ( - <> - Selected Tab: Alpha - - - ), - tabpanel: { focusable: false }, - } - : tabObj - ) } - { ...valueProps } - /> - ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // Tab should initially focus the first tab in the tablist, which - // is Alpha. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - - // In this case, the tabpanel container is skipped and focus is - // moved directly to its contents - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'button', { - name: 'Alpha Button', - } ) - ).toHaveFocus(); - } ); - - it( 'should select tabs in the tablist when using the left and right arrow keys when automatic tab activation is enabled', async () => { - const mockOnValueChange = jest.fn(); - const user = userEvent.setup(); - - const valueProps = - _mode === 'Uncontrolled' - ? { defaultValue: 'alpha' } - : { value: 'alpha' }; - - render( - - ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed - // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); - - // Focus the tablist (and the selected tab, alpha) - // Tab should initially focus the first tab in the tablist, which - // is Alpha. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - - // Press the right arrow key to select the beta tab - await user.keyboard( '{ArrowRight}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'beta', - expect.anything() - ); - - // Press the right arrow key to select the gamma tab - await user.keyboard( '{ArrowRight}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Gamma', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Gamma', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'gamma', - expect.anything() - ); - - // Press the left arrow key to select the beta tab - await user.keyboard( '{ArrowLeft}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 3 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'beta', - expect.anything() - ); - } ); - - it( 'should not automatically select tabs in the tablist when pressing the left and right arrow keys by default (manual tab activation)', async () => { - const mockOnValueChange = jest.fn(); - - const user = userEvent.setup(); - - const valueProps = - _mode === 'Uncontrolled' - ? { defaultValue: 'alpha' } - : { value: 'alpha' }; - - render( - - ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed - // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); - - // Focus the tablist (and the selected tab, alpha) - // Tab should initially focus the first tab in the tablist, which - // is Alpha. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - - // Press the right arrow key to move focus to the beta tab, - // but without selecting it - await user.keyboard( '{ArrowRight}' ); - - expect( - screen.getByRole( 'tab', { - selected: false, - name: 'Beta', - } ) - ).toHaveFocus(); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); - - // Press the space key to click the beta tab, and select it. - // The same should be true with any other mean of clicking the tab button - // (ie. mouse click, enter key). - await user.keyboard( '{ }' ); - - await waitFor( () => - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus() - ); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'beta', - expect.anything() - ); - } ); - - it( 'should not select tabs in the tablist when using the up and down arrow keys, unless the `orientation` prop is set to `vertical`', async () => { - const mockOnValueChange = jest.fn(); - - const user = userEvent.setup(); - - const valueProps = - _mode === 'Uncontrolled' - ? { defaultValue: 'alpha' } - : { value: 'alpha' }; - - const { rerender } = render( - - ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed - // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); - - // Focus the tablist (and the selected tab, alpha) - // Tab should initially focus the first tab in the tablist, which - // is Alpha. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - - // Press the up arrow key, but the focused/selected tab does not change. - await user.keyboard( '{ArrowUp}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); - - // Press the down arrow key, but the focused/selected tab does not change. - await user.keyboard( '{ArrowDown}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); - - // Change the orientation to "vertical" and rerender the component. - rerender( - - ); - - // Pressing the down arrow key now selects the next tab (beta). - await user.keyboard( '{ArrowDown}' ); - await user.keyboard( '{Enter}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'beta', - expect.anything() - ); - - // Pressing the up arrow key now selects the previous tab (alpha). - await user.keyboard( '{ArrowUp}' ); - await user.keyboard( '{Enter}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'alpha', - expect.anything() - ); - } ); - - it( 'should loop tab focus at the end of the tablist when using arrow keys', async () => { - const mockOnValueChange = jest.fn(); - - const user = userEvent.setup(); - - const valueProps = - _mode === 'Uncontrolled' - ? { defaultValue: 'alpha' } - : { value: 'alpha' }; - - render( - - ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed - // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); - - // Focus the tablist (and the selected tab, alpha) - // Tab should initially focus the first tab in the tablist, which - // is Alpha. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - - // Press the left arrow key to loop around and select the gamma tab - await user.keyboard( '{ArrowLeft}' ); - await user.keyboard( '{Enter}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Gamma', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Gamma', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'gamma', - expect.anything() - ); - - // Press the right arrow key to loop around and select the alpha tab - await user.keyboard( '{ArrowRight}' ); - await user.keyboard( '{Enter}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'alpha', - expect.anything() - ); - } ); - - it( 'should swap the left and right arrow keys when selecting tabs if the writing direction is set to RTL', async () => { - const mockOnValueChange = jest.fn(); - - const user = userEvent.setup(); - - const valueProps = - _mode === 'Uncontrolled' - ? { defaultValue: 'alpha' } - : { value: 'alpha' }; - - render( - - - - ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed - // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); - - // Focus the tablist (and the selected tab, alpha) - // Tab should initially focus the first tab in the tablist, which - // is Alpha. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - - // Press the left arrow key to select the beta tab - await user.keyboard( '{ArrowLeft}' ); - await user.keyboard( '{Enter}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'beta', - expect.anything() - ); - - // Press the left arrow key to select the gamma tab - await user.keyboard( '{ArrowLeft}' ); - await user.keyboard( '{Enter}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Gamma', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Gamma', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 2 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'gamma', - expect.anything() - ); - - // Press the right arrow key to select the beta tab - await user.keyboard( '{ArrowRight}' ); - await user.keyboard( '{Enter}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 3 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'beta', - expect.anything() - ); - } ); - - it( 'should focus tabs in the tablist even if disabled', async () => { - const mockOnValueChange = jest.fn(); - - const user = userEvent.setup(); - - const valueProps = - _mode === 'Uncontrolled' - ? { defaultValue: 'alpha' } - : { value: 'alpha' }; - - render( - - ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( 'Alpha' ); - - // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed - // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); - - // Focus the tablist (and the selected tab, alpha) - // Tab should initially focus the first tab in the tablist, which - // is Alpha. - await user.keyboard( '{Tab}' ); - expect( - await screen.findByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - - // Pressing the right arrow key moves focus to the beta tab, but alpha - // remains the selected tab because beta is disabled. - await user.keyboard( '{ArrowRight}' ); - await user.keyboard( '{Enter}' ); - - expect( - screen.getByRole( 'tab', { - selected: false, - name: 'Beta', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 0 ); - - // Press the right arrow key to select the gamma tab - await user.keyboard( '{ArrowRight}' ); - await user.keyboard( '{Enter}' ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Gamma', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Gamma', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'gamma', - expect.anything() - ); - } ); - } ); - - describe( 'When `selectedId` is changed by the controlling component [Controlled]', () => { - describe.each( [ true, false ] )( - 'and automatic tab activation is %s', - ( selectOnMove ) => { - it( 'should continue to handle arrow key navigation properly', async () => { - const user = userEvent.setup(); - - const { rerender } = render( - - ); - - // Beta is the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( - 'Beta' - ); - - // Tab key should focus the currently first tab (if manual activation mode), - // or the currently selected tab (if automatic activation mode). - await user.keyboard( '{Tab}' ); - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - - rerender( - - ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Gamma', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tab', { - selected: false, - name: 'Beta', - } ) - ).toHaveFocus(); - - // Arrow left should move focus to the previous tab. - await user.keyboard( '{ArrowLeft}' ); - - await waitFor( () => - expect( - screen.getByRole( 'tab', { - selected: selectOnMove, - name: 'Alpha', - } ) - ).toHaveFocus() - ); - } ); - - it( 'should focus the correct tab when tabbing out and back into the tablist', async () => { - const user = userEvent.setup(); - - const { rerender } = render( - <> - - - - ); - - // Beta is the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( - 'Beta' - ); - - // Tab key should focus the currently selected tab, which is Beta. - await user.keyboard( '{Tab}' ); - await user.keyboard( '{Tab}' ); - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - - // Change the selected tab to gamma via a controlled update. - rerender( - <> - - - - ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Gamma', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tab', { - selected: false, - name: 'Beta', - } ) - ).toHaveFocus(); - - // Press shift+tab, move focus to the button before Tabs - await user.keyboard( '{Shift>}{Tab}{/Shift}' ); - expect( - screen.getByRole( 'button', { name: 'Focus me' } ) - ).toHaveFocus(); - - // Press tab, move focus back to the tablist - await user.keyboard( '{Tab}' ); - - expect( - screen.getByRole( 'tab', { - name: 'Beta', - } ) - ).toHaveFocus(); - } ); - } - ); - } ); - } ); - - describe( 'miscellaneous runtime changes', () => { - describe( 'removing a tab', () => { - describe( 'with no explicitly set initial tab', () => { - it( 'should not select a new tab when the selected tab is removed', async () => { - const mockOnValueChange = jest.fn(); - - const user = userEvent.setup(); - - const { rerender } = render( - - ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( - 'Alpha' - ); - - // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed - // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); - - // Select gamma - await user.click( - screen.getByRole( 'tab', { name: 'Gamma' } ) - ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Gamma', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Gamma', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'gamma', - expect.anything() - ); - - // Remove gamma - rerender( - - ); - - expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); - - // Falls back to the first tab. - expect( - screen.getByRole( 'tab', { - name: 'Alpha', - selected: true, - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - } ); - } ); - - describe.each( [ - [ 'defaultValue', 'Uncontrolled', UncontrolledTabs ], - [ 'value', 'Controlled', ControlledTabs ], - ] )( - 'when using the `%s` prop [%s]', - ( propName, mode, Component ) => { - it( 'should handle the selected tab being removed', async () => { - const mockOnValueChange = jest.fn(); - - const initialComponentProps = { - tabs: TABS, - [ propName ]: 'gamma', - onValueChange: mockOnValueChange, - }; - - const { rerender } = render( - - ); - - // Gamma is the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( - 'Gamma' - ); - - // Remove gamma - rerender( - - ); - - expect( screen.getAllByRole( 'tab' ) ).toHaveLength( - 2 - ); - - if ( mode === 'Uncontrolled' ) { - // Falls back to the first tab. - expect( - screen.getByRole( 'tab', { - name: 'Alpha', - selected: true, - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - } - - if ( mode === 'Controlled' ) { - // No tab should be selected i.e. it doesn't fall back to first tab. - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument(); - expect( - screen.queryByRole( 'tabpanel' ) - ).not.toBeInTheDocument(); - } - - // Re-add gamma. - rerender( ); - - expect( screen.getAllByRole( 'tab' ) ).toHaveLength( - TABS.length - ); - - if ( mode === 'Uncontrolled' ) { - // First tab stays selected. - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - } - - if ( mode === 'Controlled' ) { - // Gamma becomes selected again. - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Gamma', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Gamma', - } ) - ).toBeVisible(); - } - - expect( mockOnValueChange ).not.toHaveBeenCalled(); - } ); - - it( `should not fall back to the tab matching the \`${ propName }\` prop when a different selected tab is removed`, async () => { - const mockOnValueChange = jest.fn(); - - const initialComponentProps = { - tabs: TABS, - [ propName ]: 'gamma', - onValueChange: mockOnValueChange, - }; - - const user = userEvent.setup(); - - const { rerender } = render( - - ); - - // Gamma is the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( - 'Gamma' - ); - - // Select alpha - await user.click( - screen.getByRole( 'tab', { name: 'Alpha' } ) - ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'alpha', - expect.anything() - ); - - // Remove alpha - rerender( - - ); - - expect( screen.getAllByRole( 'tab' ) ).toHaveLength( - 2 - ); - - if ( mode === 'Uncontrolled' ) { - // Falls back to the first available tab. - expect( - screen.getByRole( 'tab', { - name: 'Beta', - selected: true, - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - } - - if ( mode === 'Controlled' ) { - // No tab should be selected i.e. it doesn't fall back to gamma, - // even if it matches the `defaultValue` prop. - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument(); - // No tabpanel should be rendered either - expect( - screen.queryByRole( 'tabpanel' ) - ).not.toBeInTheDocument(); - } - - // Re-add alpha. Alpha becomes selected again. - rerender( ); - - expect( screen.getAllByRole( 'tab' ) ).toHaveLength( - TABS.length - ); - - if ( mode === 'Uncontrolled' ) { - // Beta stays selected. - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - } - - if ( mode === 'Controlled' ) { - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - } - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - } ); - } - ); - } ); - - describe( 'adding a tab', () => { - describe.each( [ - [ 'defaultValue', 'Uncontrolled', UncontrolledTabs ], - [ 'value', 'Controlled', ControlledTabs ], - ] )( - 'when using the `%s` prop [%s]', - ( propName, mode, Component ) => { - it( `should select a newly added tab if it matches the \`${ propName }\` prop`, async () => { - const mockOnValueChange = jest.fn(); - - const initialComponentProps = { - tabs: TABS, - [ propName ]: 'delta', - onValueChange: mockOnValueChange, - }; - - const { rerender } = render( - - ); - - if ( mode === 'Uncontrolled' ) { - // Falls back to the first tab. - await waitForComponentToBeInitializedWithSelectedTab( - 'Alpha' - ); - } - - if ( mode === 'Controlled' ) { - // No initially selected tabs or tabpanels, since the `value` - // prop is not matching any known tabs. - await waitForComponentToBeInitializedWithSelectedTab( - undefined - ); - } - - expect( mockOnValueChange ).not.toHaveBeenCalled(); - - // Re-render with delta added. - rerender( - - ); - - if ( mode === 'Uncontrolled' ) { - // Alpha stays selected. - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - } - - if ( mode === 'Controlled' ) { - // Delta becomes selected - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Delta', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Delta', - } ) - ).toBeVisible(); - } - - expect( mockOnValueChange ).not.toHaveBeenCalled(); - } ); - } - ); - } ); - describe( 'a tab becomes disabled', () => { - describe.each( [ - [ 'defaultValue', 'Uncontrolled', UncontrolledTabs ], - [ 'value', 'Controlled', ControlledTabs ], - ] )( - 'when using the `%s` prop [%s]', - ( propName, mode, Component ) => { - it( `should keep the initial tab matching the \`${ propName }\` prop as selected even if it becomes disabled`, async () => { - const mockOnValueChange = jest.fn(); - - const initialComponentProps = { - tabs: TABS, - [ propName ]: 'beta', - onValueChange: mockOnValueChange, - }; - - const { rerender } = render( - - ); - - // Beta is the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( - 'Beta' - ); - - expect( mockOnValueChange ).not.toHaveBeenCalled(); - - // Re-render with beta disabled. - rerender( - - ); - - // Beta continues to be selected and focused, even if it is disabled. - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - // Re-enable beta. - rerender( ); - - // Beta continues to be selected and focused. - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).not.toHaveBeenCalled(); - } ); - - it( 'should handle the user-selected tab becoming disabled', async () => { - const mockOnValueChange = jest.fn(); - - const user = userEvent.setup(); - - const initialComponentProps = { - tabs: TABS, - [ propName ]: 'alpha', - onValueChange: mockOnValueChange, - }; - - const { rerender } = render( - - ); - - // Alpha is automatically selected as the selected tab. - await waitForComponentToBeInitializedWithSelectedTab( - 'Alpha' - ); - - // TODO: re-enable once https://github.com/mui/base-ui/issues/2097 is fixed - // expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - // expect( mockOnValueChange ).toHaveBeenLastCalledWith( 'alpha' ); - - // Click on beta tab, beta becomes selected. - await user.click( - screen.getByRole( 'tab', { name: 'Beta' } ) - ); - - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - expect( mockOnValueChange ).toHaveBeenLastCalledWith( - 'beta', - expect.anything() - ); - - // Re-render with beta disabled. - rerender( - - ); - - if ( mode === 'Uncontrolled' ) { - // Alpha becomes the selected tab. - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - } - - if ( mode === 'Controlled' ) { - // Beta continues to be selected, even if it is disabled. - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toHaveFocus(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - } - - // Re-enable beta. - rerender( ); - - if ( mode === 'Uncontrolled' ) { - // Alpha stays selected. - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Alpha', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Alpha', - } ) - ).toBeVisible(); - } - - if ( mode === 'Controlled' ) { - // Beta continues to be selected and focused. - expect( - screen.getByRole( 'tab', { - selected: true, - name: 'Beta', - } ) - ).toBeVisible(); - expect( - screen.getByRole( 'tabpanel', { - name: 'Beta', - } ) - ).toBeVisible(); - } - - expect( mockOnValueChange ).toHaveBeenCalledTimes( 1 ); - } ); - } - ); - } ); - } ); -} ); From 6e8038abae921edadacd7c6da0215af8740b399a Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 16:25:41 +0100 Subject: [PATCH 09/38] Update package.json --- package-lock.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package-lock.json b/package-lock.json index 99eb6f546f832a..64a1cd4c73a5a5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62110,6 +62110,7 @@ "dependencies": { "@base-ui/react": "^1.0.0", "@wordpress/a11y": "file:../a11y", + "@wordpress/compose": "file:../compose", "@wordpress/element": "file:../element", "@wordpress/i18n": "file:../i18n", "@wordpress/icons": "file:../icons", From 29fd58156cec330ecbbaad7384c268ca1d80946b Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 15 Jan 2026 16:45:31 +0100 Subject: [PATCH 10/38] remove cursor files (added by mistake) --- .cursor/commands/adapt-code-for-repo.md | 3 --- .cursor/commands/import-normalize.md | 5 ----- 2 files changed, 8 deletions(-) delete mode 100644 .cursor/commands/adapt-code-for-repo.md delete mode 100644 .cursor/commands/import-normalize.md diff --git a/.cursor/commands/adapt-code-for-repo.md b/.cursor/commands/adapt-code-for-repo.md deleted file mode 100644 index bb9b4c4fbf28a7..00000000000000 --- a/.cursor/commands/adapt-code-for-repo.md +++ /dev/null @@ -1,3 +0,0 @@ -# adapt-code-for-repo - -I just added these file(s) from a different repo. Make the minimal changes necessary to make it work in this repo. Give higher priority to package-specific conventions if necessary. Also, run the `import-normalize` command to normalize the imports. diff --git a/.cursor/commands/import-normalize.md b/.cursor/commands/import-normalize.md deleted file mode 100644 index 56c41d93fb671c..00000000000000 --- a/.cursor/commands/import-normalize.md +++ /dev/null @@ -1,5 +0,0 @@ -# import-normalize - -1. Remove the doc comments preceding the import statements. -2. Make the minimal changes required to make [this ordering rule lint](../../.eslintrc.strict.js) pass. -3. Remove any extra blank lines between import statements. From 207516a6510699b84811391e959c06a59ea62bbb Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Jan 2026 17:59:55 +0100 Subject: [PATCH 11/38] Restore tooltip in stories --- packages/ui/src/tabs/stories/index.story.tsx | 137 +++++++++---------- 1 file changed, 68 insertions(+), 69 deletions(-) diff --git a/packages/ui/src/tabs/stories/index.story.tsx b/packages/ui/src/tabs/stories/index.story.tsx index 4781e266a44474..4782ed29c50941 100644 --- a/packages/ui/src/tabs/stories/index.story.tsx +++ b/packages/ui/src/tabs/stories/index.story.tsx @@ -1,8 +1,8 @@ import { fn } from 'storybook/test'; import type { Meta, StoryObj } from '@storybook/react-vite'; -import { useState } from '@wordpress/element'; -// import { link, more, wordpress } from '@wordpress/icons'; -import { Tabs } from '../..'; +import { useState, cloneElement } from '@wordpress/element'; +import { link, more, wordpress } from '@wordpress/icons'; +import { Tabs, Tooltip } from '../..'; const meta: Meta< typeof Tabs.Root > = { title: 'Design System/Components/Tabs', @@ -238,76 +238,75 @@ export const WithDisabledTab: StoryObj< typeof Tabs.Root > = { }, }; -// const LinkIcon = ( props: React.SVGProps< SVGSVGElement > ) => { -// return cloneElement( link, props ); -// }; +const LinkIcon = ( props: React.SVGProps< SVGSVGElement > ) => { + return cloneElement( link, props ); +}; -// const MoreIcon = ( props: React.SVGProps< SVGSVGElement > ) => { -// return cloneElement( more, props ); -// }; +const MoreIcon = ( props: React.SVGProps< SVGSVGElement > ) => { + return cloneElement( more, props ); +}; -// const WordpressIcon = ( props: React.SVGProps< SVGSVGElement > ) => { -// return cloneElement( wordpress, props ); -// }; +const WordpressIcon = ( props: React.SVGProps< SVGSVGElement > ) => { + return cloneElement( wordpress, props ); +}; -// const tabWithIconsData = [ -// { -// value: 'tab1', -// label: 'Tab one', -// icon: WordpressIcon, -// }, -// { -// value: 'tab2', -// label: 'Tab two', -// icon: LinkIcon, -// }, -// { -// value: 'tab3', -// label: 'Tab three', -// icon: MoreIcon, -// }, -// ]; +const tabWithIconsData = [ + { + value: 'tab1', + label: 'Tab one', + icon: WordpressIcon, + }, + { + value: 'tab2', + label: 'Tab two', + icon: LinkIcon, + }, + { + value: 'tab3', + label: 'Tab three', + icon: MoreIcon, + }, +]; -// TODO: tooltip still not added to package -// export const WithTabIconsAndTooltips: StoryObj< typeof Tabs.Root > = { -// args: { -// ...Default.args, -// children: ( -// <> -// -// { tabWithIconsData.map( -// ( { value, label, icon: Icon } ) => ( -// -// } -// > -// { /* TODO: potentially refactor with new Icon component */ } -// -// -// -// { label } -// -// -// ) -// ) } -// -// { tabWithIconsData.map( ( { value, label } ) => ( -// -// -// Selected tab: { label } -// -// -// ) ) } -// -// ), -// }, -// }; +export const WithTabIconsAndTooltips: StoryObj< typeof Tabs.Root > = { + args: { + ...Default.args, + children: ( + <> + + { tabWithIconsData.map( + ( { value, label, icon: Icon } ) => ( + + } + > + { /* TODO: potentially refactor with new Icon component */ } + + + + { label } + + + ) + ) } + + { tabWithIconsData.map( ( { value, label } ) => ( + + + Selected tab: { label } + + + ) ) } + + ), + }, +}; export const WithPanelsAlwaysMounted: StoryObj< typeof Tabs.Root > = { args: { From 2fdc5da2da76304801f20ea8e3fb562a3d0e4e0d Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Jan 2026 18:01:05 +0100 Subject: [PATCH 12/38] Move best practices file --- packages/ui/src/tabs/{ => stories}/best-practices.mdx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename packages/ui/src/tabs/{ => stories}/best-practices.mdx (100%) diff --git a/packages/ui/src/tabs/best-practices.mdx b/packages/ui/src/tabs/stories/best-practices.mdx similarity index 100% rename from packages/ui/src/tabs/best-practices.mdx rename to packages/ui/src/tabs/stories/best-practices.mdx From 48f8ea7aa59592e90fabfe3b284ccf51c5c7b45a Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 16 Jan 2026 18:05:38 +0100 Subject: [PATCH 13/38] Fix storybook mdx --- packages/ui/src/tabs/stories/best-practices.mdx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/tabs/stories/best-practices.mdx b/packages/ui/src/tabs/stories/best-practices.mdx index 2da8b196584497..e30a9ba558688b 100644 --- a/packages/ui/src/tabs/stories/best-practices.mdx +++ b/packages/ui/src/tabs/stories/best-practices.mdx @@ -1,7 +1,7 @@ import { Meta } from '@storybook/addon-docs/blocks'; -import * as TabsStories from './stories/index.stories'; +import * as TabsStories from './index.story'; - + # Tabs @@ -16,7 +16,7 @@ import { Tabs } from '@automattic/design-system'; const MyUncontrolledTabs = () => ( console.log( 'New selected tab: ', tab ) } defaultValue="tab2" > From ff894db3e8569e259bdeb878229f1136d7f5d06d Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 15:59:40 +0100 Subject: [PATCH 14/38] Revert "Adjust --wpds-color-stroke-interactive-neutral-strong" This reverts commit 9ef48ac232f5fc4a3bf49d16d17dd9ac2a04c7aa. --- packages/theme/src/prebuilt/css/design-tokens.css | 2 +- packages/theme/src/prebuilt/ts/color-tokens.ts | 6 ++---- packages/theme/tokens/color.json | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/theme/src/prebuilt/css/design-tokens.css b/packages/theme/src/prebuilt/css/design-tokens.css index bf35662cb54508..f420be9c7d910d 100644 --- a/packages/theme/src/prebuilt/css/design-tokens.css +++ b/packages/theme/src/prebuilt/css/design-tokens.css @@ -86,7 +86,7 @@ --wpds-color-stroke-interactive-neutral: #8a8a8a; /* Accessible stroke color used for interactive neutrally-toned elements with normal emphasis. */ --wpds-color-stroke-interactive-neutral-active: #6c6c6c; /* Accessible stroke color used for interactive neutrally-toned elements with normal emphasis that are hovered, focused, or active. */ --wpds-color-stroke-interactive-neutral-disabled: #d8d8d8; /* Accessible stroke color used for interactive elements with normal emphasis, in their disabled state, regardless of the tone. */ - --wpds-color-stroke-interactive-neutral-strong: #2d2d2d; /* Accessible stroke color used for interactive neutrally-toned elements with strong emphasis. */ + --wpds-color-stroke-interactive-neutral-strong: #6c6c6c; /* Accessible stroke color used for interactive neutrally-toned elements with strong emphasis. */ --wpds-color-stroke-surface-brand: #a3b1d4; /* Decorative stroke color used to define brand-toned surface boundaries with normal emphasis. */ --wpds-color-stroke-surface-brand-strong: #3858e9; /* Decorative stroke color used to define brand-toned surface boundaries with strong emphasis. */ --wpds-color-stroke-surface-error: #daa39b; /* Decorative stroke color used to define error-toned surface boundaries with normal emphasis. */ diff --git a/packages/theme/src/prebuilt/ts/color-tokens.ts b/packages/theme/src/prebuilt/ts/color-tokens.ts index 90ad68c49ff585..3f768f8e1c1e6c 100644 --- a/packages/theme/src/prebuilt/ts/color-tokens.ts +++ b/packages/theme/src/prebuilt/ts/color-tokens.ts @@ -96,6 +96,7 @@ export default { 'bg-stroke4': [ 'bg-thumb-neutral-weak-active', 'stroke-interactive-neutral-active', + 'stroke-interactive-neutral-strong', ], 'bg-stroke2': [ 'bg-thumb-neutral-disabled', @@ -105,10 +106,7 @@ export default { ], 'bg-stroke1': [ 'bg-track-neutral-weak', 'stroke-surface-neutral-weak' ], 'bg-bgFillInverted2': [ 'bg-interactive-neutral-strong-active' ], - 'bg-bgFillInverted1': [ - 'bg-interactive-neutral-strong', - 'stroke-interactive-neutral-strong', - ], + 'bg-bgFillInverted1': [ 'bg-interactive-neutral-strong' ], 'bg-fgFillInverted': [ 'fg-interactive-neutral-strong', 'fg-interactive-neutral-strong-active', diff --git a/packages/theme/tokens/color.json b/packages/theme/tokens/color.json index c4083cbca0f686..21cbdf9c9e2213 100644 --- a/packages/theme/tokens/color.json +++ b/packages/theme/tokens/color.json @@ -1466,7 +1466,7 @@ "$description": "Accessible stroke color used for interactive elements with normal emphasis, in their disabled state, regardless of the tone." }, "neutral-strong": { - "$value": "{wpds-color.primitive.bg.bgFillInverted1}", + "$value": "{wpds-color.primitive.bg.stroke4}", "$description": "Accessible stroke color used for interactive neutrally-toned elements with strong emphasis." }, "brand": { From 5c9b9f22ba00c49538c3575c310e29e2c3525320 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 15:59:59 +0100 Subject: [PATCH 15/38] Fix docs --- packages/ui/src/tabs/stories/best-practices.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/tabs/stories/best-practices.mdx b/packages/ui/src/tabs/stories/best-practices.mdx index e30a9ba558688b..c37a2370d769cd 100644 --- a/packages/ui/src/tabs/stories/best-practices.mdx +++ b/packages/ui/src/tabs/stories/best-practices.mdx @@ -12,7 +12,7 @@ import * as TabsStories from './index.story'; `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. ```jsx -import { Tabs } from '@automattic/design-system'; +import { Tabs } from '@wordpress/ui'; const MyUncontrolledTabs = () => ( Date: Thu, 29 Jan 2026 16:59:04 +0100 Subject: [PATCH 16/38] Remove custom focusable prop in favour of using tabIndex directly --- packages/ui/src/tabs/panel.tsx | 6 +----- packages/ui/src/tabs/stories/index.story.tsx | 6 +++--- packages/ui/src/tabs/test/index.test.tsx | 10 +++++----- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/ui/src/tabs/panel.tsx b/packages/ui/src/tabs/panel.tsx index f8a3eb73bc188d..01eef5bdeae3a3 100644 --- a/packages/ui/src/tabs/panel.tsx +++ b/packages/ui/src/tabs/panel.tsx @@ -11,14 +11,10 @@ import type { TabPanelProps } from './types'; * an [ARIA-compliant tabs pattern](https://www.w3.org/WAI/ARIA/apg/patterns/tabs/). */ export const Panel = forwardRef< HTMLDivElement, TabPanelProps >( - function TabPanel( - { className, focusable = true, tabIndex, ...otherProps }, - forwardedRef - ) { + function TabPanel( { className, ...otherProps }, forwardedRef ) { return ( diff --git a/packages/ui/src/tabs/stories/index.story.tsx b/packages/ui/src/tabs/stories/index.story.tsx index 4782ed29c50941..dd933e3ba4f9d7 100644 --- a/packages/ui/src/tabs/stories/index.story.tsx +++ b/packages/ui/src/tabs/stories/index.story.tsx @@ -342,7 +342,7 @@ export const WithNonFocusablePanels: StoryObj< typeof Tabs.Root > = { Tab 2 Tab 3 - + Selected tab: Tab 1 This tabpanel is not focusable, therefore tabbing into @@ -350,7 +350,7 @@ export const WithNonFocusablePanels: StoryObj< typeof Tabs.Root > = { - + Selected tab: Tab 2 This tabpanel is not focusable, therefore tabbing into @@ -358,7 +358,7 @@ export const WithNonFocusablePanels: StoryObj< typeof Tabs.Root > = { - + Selected tab: Tab 3 This tabpanel is not focusable, therefore tabbing into diff --git a/packages/ui/src/tabs/test/index.test.tsx b/packages/ui/src/tabs/test/index.test.tsx index 3631e0c99a494c..869ceaac8f5745 100644 --- a/packages/ui/src/tabs/test/index.test.tsx +++ b/packages/ui/src/tabs/test/index.test.tsx @@ -15,7 +15,7 @@ type Tab = { disabled?: boolean; }; tabpanel?: { - focusable?: boolean; + tabIndex?: number; }; }; @@ -100,7 +100,7 @@ const UncontrolledTabs = ( { { tabObj.content } @@ -148,7 +148,7 @@ const ControlledTabs = ( { { tabObj.content } @@ -287,7 +287,7 @@ describe( 'Tabs', () => { { tabObj.content } @@ -952,7 +952,7 @@ describe( 'Tabs', () => { ), - tabpanel: { focusable: false }, + tabpanel: { tabIndex: -1 }, } : tabObj ) } From 3531fb40112b93804106c3a29496ae6b4cde9217 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 16:59:19 +0100 Subject: [PATCH 17/38] Use Icon component instead of cloning an SVG --- packages/ui/src/tabs/tab.tsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/packages/ui/src/tabs/tab.tsx b/packages/ui/src/tabs/tab.tsx index d63ec418cb141f..7c82af179d9b8d 100644 --- a/packages/ui/src/tabs/tab.tsx +++ b/packages/ui/src/tabs/tab.tsx @@ -1,14 +1,11 @@ -import { cloneElement, forwardRef } from '@wordpress/element'; +import { forwardRef } from '@wordpress/element'; import clsx from 'clsx'; import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; import { chevronRight } from '@wordpress/icons'; +import { Icon } from '../icon'; import styles from './style.module.css'; import type { TabProps } from './types'; -const ChevronRight = ( props: React.SVGProps< SVGSVGElement > ) => { - return cloneElement( chevronRight, props ); -}; - /** * An individual interactive tab button that toggles the corresponding panel. * @@ -26,7 +23,7 @@ export const Tab = forwardRef< HTMLButtonElement, TabProps >( function Tab( { ...otherProps } > { children } - + ); } ); From e4e72b2fef2bf1d3da844cdb7f4216e42e305126 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 16:59:39 +0100 Subject: [PATCH 18/38] Fix renamed DS tokens --- packages/ui/src/tabs/style.module.css | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/ui/src/tabs/style.module.css b/packages/ui/src/tabs/style.module.css index f3bf8c91718e14..31fd661dddadd7 100644 --- a/packages/ui/src/tabs/style.module.css +++ b/packages/ui/src/tabs/style.module.css @@ -73,7 +73,7 @@ /* stylelint-disable-next-line plugin/use-logical-properties-and-values -- Physical properties are necessary for the indicator to work as expected. */ left: 0; bottom: 0; - height: var(--wpds-border-width-interactive-focus); + height: var(--wpds-border-width-focus); width: var(--active-tab-width); translate: var(--active-tab-left) 0; @@ -82,7 +82,7 @@ &[data-orientation="vertical"] { z-index: 0; - border-radius: var(--wpds-border-radius-surface-sm); + border-radius: var(--wpds-border-radius-sm); top: 0; /* stylelint-disable-next-line plugin/use-logical-properties-and-values -- Physical properties are necessary for the indicator to work as expected. */ left: 50%; @@ -96,7 +96,7 @@ &[data-orientation="vertical"] { box-sizing: border-box; border: - var(--wpds-border-width-interactive-focus) solid + var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); } } @@ -152,9 +152,9 @@ /* Outline works for Windows high contrast mode as well. */ outline: - var(--wpds-border-width-interactive-focus) solid + var(--wpds-border-width-focus) solid var(--wpds-color-stroke-focus-brand); - border-radius: var(--wpds-border-radius-surface-sm); + border-radius: var(--wpds-border-radius-sm); /* Animation */ opacity: 0; @@ -200,7 +200,7 @@ [data-orientation="vertical"][data-select-on-move="false"] &::after { content: ""; - inset: var(--wpds-border-width-interactive-focus); /* TODO: Use or create new control/interactive padding token */ + inset: var(--wpds-border-width-focus); /* TODO: Use or create new control/interactive padding token */ } } @@ -220,8 +220,6 @@ } .tab__chevron { - fill: currentColor; - height: 24px; flex-shrink: 0; margin-inline-end: calc(var(--wpds-dimension-base) * -1); /* TODO: Use or create new control/interactive padding token */ @@ -260,7 +258,7 @@ &:focus-visible { box-shadow: - 0 0 0 var(--wpds-border-width-interactive-focus) + 0 0 0 var(--wpds-border-width-focus) var(--wpds-color-stroke-focus-brand); /* Windows high contrast mode. */ From c84cc413ea2148c49ed8435921228bd149150a30 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 16:59:54 +0100 Subject: [PATCH 19/38] Use ComponentProps utility type --- packages/ui/src/tabs/types.ts | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/packages/ui/src/tabs/types.ts b/packages/ui/src/tabs/types.ts index 5914bcbebfcced..a5d600fdb71646 100644 --- a/packages/ui/src/tabs/types.ts +++ b/packages/ui/src/tabs/types.ts @@ -1,17 +1,19 @@ +import type { ReactNode } from 'react'; import type { Tabs } from '@base-ui/react/tabs'; +import type { ComponentProps } from '../utils/types'; -export type TabRootProps = Omit< Tabs.Root.Props, 'className' > & { +export type TabRootProps = ComponentProps< typeof Tabs.Root > & { /** - * The CSS class to apply. + * The content to be rendered inside the component. */ - className?: Tabs.Root.Props[ 'className' ]; + children?: ReactNode; }; -export type TabListProps = Omit< Tabs.List.Props, 'className' > & { +export type TabListProps = ComponentProps< typeof Tabs.List > & { /** - * The CSS class to apply. + * The content to be rendered inside the component. */ - className?: Tabs.List.Props[ 'className' ]; + children?: ReactNode; /** * The visual density of the tab list. * @default "default" @@ -19,21 +21,16 @@ export type TabListProps = Omit< Tabs.List.Props, 'className' > & { density?: 'compact' | 'default'; }; -export type TabProps = Omit< Tabs.Tab.Props, 'className' > & { +export type TabProps = ComponentProps< typeof Tabs.Tab > & { /** - * The CSS class to apply. + * The content to be rendered inside the component. */ - className?: Tabs.Tab.Props[ 'className' ]; + children?: ReactNode; }; -export type TabPanelProps = Omit< Tabs.Panel.Props, 'className' > & { +export type TabPanelProps = ComponentProps< typeof Tabs.Panel > & { /** - * The CSS class to apply. + * The content to be rendered inside the component. */ - className?: Tabs.Panel.Props[ 'className' ]; - /** - * Whether the tab panel should be included in the tab order. - * @default true - */ - focusable?: boolean; + children?: ReactNode; }; From ebbde974e4bab511c10b8950799ee393a2b00bbd Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 17:29:41 +0100 Subject: [PATCH 20/38] Support a second "State" argument in internal render prop type, like Base UI --- packages/ui/src/button/types.ts | 2 +- packages/ui/src/form/primitives/field/types.ts | 17 ++++++++++++----- .../ui/src/form/primitives/fieldset/types.ts | 10 ++++++++-- packages/ui/src/form/primitives/input/types.ts | 2 +- packages/ui/src/tabs/types.ts | 17 +++++++++++++---- packages/ui/src/utils/types.ts | 9 ++++++--- 6 files changed, 41 insertions(+), 16 deletions(-) diff --git a/packages/ui/src/button/types.ts b/packages/ui/src/button/types.ts index e4e34c96d0473a..0eeaa385276a13 100644 --- a/packages/ui/src/button/types.ts +++ b/packages/ui/src/button/types.ts @@ -2,7 +2,7 @@ import { type ReactNode, type HTMLAttributes } from 'react'; import type { Button as _Button } from '@base-ui/react/button'; import type { ComponentProps } from '../utils/types'; -type _ButtonProps = ComponentProps< typeof _Button >; +type _ButtonProps = ComponentProps< typeof _Button, _Button.State >; export interface ButtonProps extends Omit< _ButtonProps, 'disabled' | 'aria-pressed' > { diff --git a/packages/ui/src/form/primitives/field/types.ts b/packages/ui/src/form/primitives/field/types.ts index 30bd8bd35ed4a8..1d9774444540a9 100644 --- a/packages/ui/src/form/primitives/field/types.ts +++ b/packages/ui/src/form/primitives/field/types.ts @@ -2,7 +2,7 @@ import type { Field } from '@base-ui/react/field'; import type { ComponentProps } from '../../../utils/types'; export type FieldRootProps = Omit< - ComponentProps< typeof Field.Root >, + ComponentProps< typeof Field.Root, Field.Root.State >, | 'disabled' // TODO: Maybe allow these when we have validation support ready. | 'dirty' @@ -21,11 +21,17 @@ export type FieldRootProps = Omit< disabled?: Field.Root.Props[ 'disabled' ]; }; -export type FieldItemProps = ComponentProps< typeof Field.Item > & { +export type FieldItemProps = ComponentProps< + typeof Field.Item, + Field.Item.State +> & { children?: React.ReactNode; }; -export type FieldLabelProps = ComponentProps< typeof Field.Label > & { +export type FieldLabelProps = ComponentProps< + typeof Field.Label, + Field.Label.State +> & { /** * The label string, or the string and the element to associate it with. * @@ -44,7 +50,7 @@ export type FieldLabelProps = ComponentProps< typeof Field.Label > & { }; export type FieldControlProps = Omit< - ComponentProps< typeof Field.Control >, + ComponentProps< typeof Field.Control, Field.Control.State >, 'defaultValue' > & { children?: Field.Control.Props[ 'children' ]; @@ -55,7 +61,8 @@ export type FieldControlProps = Omit< }; export type FieldDescriptionProps = ComponentProps< - typeof Field.Description + typeof Field.Description, + Field.Description.State > & { /** * The accessible description, associated using `aria-describedby`. diff --git a/packages/ui/src/form/primitives/fieldset/types.ts b/packages/ui/src/form/primitives/fieldset/types.ts index 6c182e2e076411..84316341318cb5 100644 --- a/packages/ui/src/form/primitives/fieldset/types.ts +++ b/packages/ui/src/form/primitives/fieldset/types.ts @@ -1,11 +1,17 @@ import type { Fieldset as _Fieldset } from '@base-ui/react'; import type { ComponentProps } from '../../../utils/types'; -export type FieldsetRootProps = ComponentProps< typeof _Fieldset.Root > & { +export type FieldsetRootProps = ComponentProps< + typeof _Fieldset.Root, + _Fieldset.Root.State +> & { children?: React.ReactNode; }; -export type FieldsetLegendProps = ComponentProps< typeof _Fieldset.Legend > & { +export type FieldsetLegendProps = ComponentProps< + typeof _Fieldset.Legend, + _Fieldset.Legend.State +> & { children?: React.ReactNode; }; diff --git a/packages/ui/src/form/primitives/input/types.ts b/packages/ui/src/form/primitives/input/types.ts index 20502d5d46428a..7e288ef2be6ce3 100644 --- a/packages/ui/src/form/primitives/input/types.ts +++ b/packages/ui/src/form/primitives/input/types.ts @@ -3,7 +3,7 @@ import type { InputLayoutProps } from '../input-layout/types'; import type { ComponentProps } from '../../../utils/types'; export type InputProps = Omit< - ComponentProps< typeof Input >, + ComponentProps< typeof Input, Input.State >, 'value' | 'defaultValue' | 'type' | 'disabled' | 'prefix' | 'size' > & Pick< InputLayoutProps, 'prefix' | 'suffix' > & { diff --git a/packages/ui/src/tabs/types.ts b/packages/ui/src/tabs/types.ts index a5d600fdb71646..71d3807dd17884 100644 --- a/packages/ui/src/tabs/types.ts +++ b/packages/ui/src/tabs/types.ts @@ -2,14 +2,20 @@ import type { ReactNode } from 'react'; import type { Tabs } from '@base-ui/react/tabs'; import type { ComponentProps } from '../utils/types'; -export type TabRootProps = ComponentProps< typeof Tabs.Root > & { +export type TabRootProps = ComponentProps< + typeof Tabs.Root, + Tabs.Root.State +> & { /** * The content to be rendered inside the component. */ children?: ReactNode; }; -export type TabListProps = ComponentProps< typeof Tabs.List > & { +export type TabListProps = ComponentProps< + typeof Tabs.List, + Tabs.List.State +> & { /** * The content to be rendered inside the component. */ @@ -21,14 +27,17 @@ export type TabListProps = ComponentProps< typeof Tabs.List > & { density?: 'compact' | 'default'; }; -export type TabProps = ComponentProps< typeof Tabs.Tab > & { +export type TabProps = ComponentProps< typeof Tabs.Tab, Tabs.Tab.State > & { /** * The content to be rendered inside the component. */ children?: ReactNode; }; -export type TabPanelProps = ComponentProps< typeof Tabs.Panel > & { +export type TabPanelProps = ComponentProps< + typeof Tabs.Panel, + Tabs.Panel.State +> & { /** * The content to be rendered inside the component. */ diff --git a/packages/ui/src/utils/types.ts b/packages/ui/src/utils/types.ts index e8e9ab1f3286b9..55b7251943f1e3 100644 --- a/packages/ui/src/utils/types.ts +++ b/packages/ui/src/utils/types.ts @@ -9,9 +9,12 @@ import type { type HTMLAttributesWithRef< T extends ElementType = any > = HTMLAttributes< T > & { ref?: Ref< T > | undefined }; -type ComponentRenderFn< Props > = ( props: Props ) => ReactElement< unknown >; +type ComponentRenderFn< Props, State > = ( + props: Props, + state: State +) => ReactElement< unknown >; -export type ComponentProps< E extends ElementType > = Omit< +export type ComponentProps< E extends ElementType, S = unknown > = Omit< ComponentPropsWithoutRef< E >, 'className' | 'children' | 'render' > & { @@ -25,6 +28,6 @@ export type ComponentProps< E extends ElementType > = Omit< * element, or a function that returns a React element. */ render?: - | ComponentRenderFn< HTMLAttributesWithRef > + | ComponentRenderFn< HTMLAttributesWithRef, S > | ReactElement< Record< string, unknown > >; }; From 35100a6ce217d659f8cc289cc9dbe32e67f379ac Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 17:52:53 +0100 Subject: [PATCH 21/38] Use underscore notation for Base UI imports --- packages/ui/src/tabs/list.tsx | 8 ++++---- packages/ui/src/tabs/panel.tsx | 4 ++-- packages/ui/src/tabs/root.tsx | 4 ++-- packages/ui/src/tabs/tab.tsx | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/tabs/list.tsx b/packages/ui/src/tabs/list.tsx index 967526681e0cf7..b54ae55a6d291c 100644 --- a/packages/ui/src/tabs/list.tsx +++ b/packages/ui/src/tabs/list.tsx @@ -6,7 +6,7 @@ import { useState, } from '@wordpress/element'; import clsx from 'clsx'; -import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; +import { Tabs as _Tabs } from '@base-ui/react/tabs'; import { useMergeRefs } from '@wordpress/compose'; import styles from './style.module.css'; import type { TabListProps } from './types'; @@ -105,7 +105,7 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( ] ); return ( - ( { ...otherProps } > { children } - - + <_Tabs.Indicator className={ styles.indicator } /> + ); } ); diff --git a/packages/ui/src/tabs/panel.tsx b/packages/ui/src/tabs/panel.tsx index 01eef5bdeae3a3..6ae551234172a1 100644 --- a/packages/ui/src/tabs/panel.tsx +++ b/packages/ui/src/tabs/panel.tsx @@ -1,6 +1,6 @@ import { forwardRef } from '@wordpress/element'; import clsx from 'clsx'; -import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; +import { Tabs as _Tabs } from '@base-ui/react/tabs'; import styles from './style.module.css'; import type { TabPanelProps } from './types'; @@ -13,7 +13,7 @@ import type { TabPanelProps } from './types'; export const Panel = forwardRef< HTMLDivElement, TabPanelProps >( function TabPanel( { className, ...otherProps }, forwardedRef ) { return ( - ( function TabsRoot( { ...otherProps }, forwardedRef ) { - return ; + return <_Tabs.Root ref={ forwardedRef } { ...otherProps } />; } ); diff --git a/packages/ui/src/tabs/tab.tsx b/packages/ui/src/tabs/tab.tsx index 7c82af179d9b8d..eca230738ae9f7 100644 --- a/packages/ui/src/tabs/tab.tsx +++ b/packages/ui/src/tabs/tab.tsx @@ -1,6 +1,6 @@ import { forwardRef } from '@wordpress/element'; import clsx from 'clsx'; -import { Tabs as BaseUITabs } from '@base-ui/react/tabs'; +import { Tabs as _Tabs } from '@base-ui/react/tabs'; import { chevronRight } from '@wordpress/icons'; import { Icon } from '../icon'; import styles from './style.module.css'; @@ -17,13 +17,13 @@ export const Tab = forwardRef< HTMLButtonElement, TabProps >( function Tab( forwardedRef ) { return ( - { children } - + ); } ); From 867d1a5f0c4b98806c4a6ccb11976cb62f9fbdc0 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 18:47:20 +0100 Subject: [PATCH 22/38] Fix unit tests --- packages/ui/src/tabs/test/index.test.tsx | 29 +++--------------------- 1 file changed, 3 insertions(+), 26 deletions(-) diff --git a/packages/ui/src/tabs/test/index.test.tsx b/packages/ui/src/tabs/test/index.test.tsx index 869ceaac8f5745..5dc79aabe8b7c3 100644 --- a/packages/ui/src/tabs/test/index.test.tsx +++ b/packages/ui/src/tabs/test/index.test.tsx @@ -100,7 +100,7 @@ const UncontrolledTabs = ( { { tabObj.content } @@ -148,7 +148,7 @@ const ControlledTabs = ( { { tabObj.content } @@ -157,9 +157,6 @@ const ControlledTabs = ( { ); }; -let originalGetClientRects: typeof window.HTMLElement.prototype.getClientRects; -let originalScrollTo: typeof Element.prototype.scrollTo; - async function waitForComponentToBeInitializedWithSelectedTab( selectedTabName: string | undefined ) { @@ -200,26 +197,6 @@ async function waitForComponentToBeInitializedWithSelectedTab( } describe( 'Tabs', () => { - beforeAll( () => { - originalGetClientRects = window.HTMLElement.prototype.getClientRects; - // Mocking `getClientRects()` is necessary to pass a check performed by - // the `focus.tabbable.find()` and by the `focus.focusable.find()` functions - // from the `@wordpress/dom` package. - // @ts-expect-error We're not trying to comply to the DOM spec, only mocking - window.HTMLElement.prototype.getClientRects = function () { - return [ 'trick-jsdom-into-having-size-for-element-rect' ]; - }; - - // Mock scrollTo since it's not available in JSDOM - originalScrollTo = Element.prototype.scrollTo; - Element.prototype.scrollTo = jest.fn(); - } ); - - afterAll( () => { - window.HTMLElement.prototype.getClientRects = originalGetClientRects; - Element.prototype.scrollTo = originalScrollTo; - } ); - describe( 'Adherence to spec and basic behavior', () => { it( 'should apply the correct roles, semantics and attributes', async () => { render( @@ -932,7 +909,7 @@ describe( 'Tabs', () => { ).toHaveFocus(); } ); - it( 'should not focus the tabpanel container when its `focusable` property is set to `false`', async () => { + it( 'should not focus the tabpanel container when it is not tabbable', async () => { const user = userEvent.setup(); const valueProps = From 5a0b8b6a1b20e9cfa1a9bccab01f3c1452e3917d Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 19:00:57 +0100 Subject: [PATCH 23/38] Simplify overflow measurement (remove unnecessary variables and checks) --- packages/ui/src/tabs/list.tsx | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/tabs/list.tsx b/packages/ui/src/tabs/list.tsx index b54ae55a6d291c..572395a8cf34e1 100644 --- a/packages/ui/src/tabs/list.tsx +++ b/packages/ui/src/tabs/list.tsx @@ -46,20 +46,8 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( return; } - // Grab a local reference to the list element to ensure it remains stable - // during the effect and the event listeners. - const localListEl = listEl; - - function measureOverflow() { - if ( ! localListEl ) { - setOverflow( { - first: false, - last: false, - } ); - return; - } - - const { scrollWidth, clientWidth, scrollLeft } = localListEl; + const measureOverflow = () => { + const { scrollWidth, clientWidth, scrollLeft } = listEl; setOverflow( { first: scrollLeft > DEFAULT_SCROLL_MARGIN, @@ -67,12 +55,13 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( scrollLeft + clientWidth < scrollWidth - DEFAULT_SCROLL_MARGIN, } ); - } + }; const resizeObserver = new ResizeObserver( measureOverflow ); - resizeObserver.observe( localListEl ); + resizeObserver.observe( listEl ); + let scrollTick = false; - function throttleMeasureOverflowOnScroll() { + const throttleMeasureOverflowOnScroll = () => { if ( ! scrollTick ) { requestAnimationFrame( () => { measureOverflow(); @@ -80,8 +69,8 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( } ); scrollTick = true; } - } - localListEl.addEventListener( + }; + listEl.addEventListener( 'scroll', throttleMeasureOverflowOnScroll, { passive: true } @@ -91,7 +80,7 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( measureOverflow(); return () => { - localListEl.removeEventListener( + listEl.removeEventListener( 'scroll', throttleMeasureOverflowOnScroll ); From 29f0b259299ed566ced7110552b2a39163b1545b Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 19:06:21 +0100 Subject: [PATCH 24/38] remove stale CHANGELOG entry --- packages/theme/CHANGELOG.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/theme/CHANGELOG.md b/packages/theme/CHANGELOG.md index 194efc15dbb1d4..d7ab9e27c52fad 100644 --- a/packages/theme/CHANGELOG.md +++ b/packages/theme/CHANGELOG.md @@ -43,7 +43,6 @@ - `--wpds-color-bg-interactive-neutral-strong-disabled` from `#d2d2d2` to `#e2e2e2`. - `--wpds-color-bg-interactive-neutral-weak-disabled` from `#e2e2e2` to `#00000000`. - `--wpds-color-fg-interactive-neutral-strong-disabled` from `#6d6d6d` to `#8a8a8a`. -- Tweaked the value of `--wpds-color-stroke-interactive-neutral-strong` from `#6c6c6c` to `#2d2d2d` ([#74652](https://github.com/WordPress/gutenberg/pull/74652)). ### New Features From 823dff02f2dec039a778292ef7b59e4242d99c27 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Thu, 29 Jan 2026 19:11:45 +0100 Subject: [PATCH 25/38] Remove wrong package references --- packages/ui/src/tabs/stories/best-practices.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/tabs/stories/best-practices.mdx b/packages/ui/src/tabs/stories/best-practices.mdx index c37a2370d769cd..1665e3b88d64d8 100644 --- a/packages/ui/src/tabs/stories/best-practices.mdx +++ b/packages/ui/src/tabs/stories/best-practices.mdx @@ -42,7 +42,7 @@ const MyUncontrolledTabs = () => ( 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`. ```tsx -import { Tabs } from '@automattic/design-system'; +import { Tabs } from '@wordpress/ui'; const [ selectedTabId, setSelectedTabId ] = useState< string | undefined | null From 4111ef71e5e10dbd36c6bb00c389479dbdcc406c Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 30 Jan 2026 12:35:46 +0100 Subject: [PATCH 26/38] Revert "Support a second "State" argument in internal render prop type, like Base UI" This reverts commit 36bb104c497c7bdb8871a6118a1f6be901873922. --- packages/ui/src/button/types.ts | 2 +- .../ui/src/form/primitives/field/types.ts | 17 ++++--------- .../ui/src/form/primitives/fieldset/types.ts | 10 ++------ .../ui/src/form/primitives/input/types.ts | 2 +- packages/ui/src/tabs/types.ts | 17 ++++--------- packages/ui/src/utils/types.ts | 24 +++++++++---------- 6 files changed, 24 insertions(+), 48 deletions(-) diff --git a/packages/ui/src/button/types.ts b/packages/ui/src/button/types.ts index 0eeaa385276a13..e4e34c96d0473a 100644 --- a/packages/ui/src/button/types.ts +++ b/packages/ui/src/button/types.ts @@ -2,7 +2,7 @@ import { type ReactNode, type HTMLAttributes } from 'react'; import type { Button as _Button } from '@base-ui/react/button'; import type { ComponentProps } from '../utils/types'; -type _ButtonProps = ComponentProps< typeof _Button, _Button.State >; +type _ButtonProps = ComponentProps< typeof _Button >; export interface ButtonProps extends Omit< _ButtonProps, 'disabled' | 'aria-pressed' > { diff --git a/packages/ui/src/form/primitives/field/types.ts b/packages/ui/src/form/primitives/field/types.ts index 1d9774444540a9..30bd8bd35ed4a8 100644 --- a/packages/ui/src/form/primitives/field/types.ts +++ b/packages/ui/src/form/primitives/field/types.ts @@ -2,7 +2,7 @@ import type { Field } from '@base-ui/react/field'; import type { ComponentProps } from '../../../utils/types'; export type FieldRootProps = Omit< - ComponentProps< typeof Field.Root, Field.Root.State >, + ComponentProps< typeof Field.Root >, | 'disabled' // TODO: Maybe allow these when we have validation support ready. | 'dirty' @@ -21,17 +21,11 @@ export type FieldRootProps = Omit< disabled?: Field.Root.Props[ 'disabled' ]; }; -export type FieldItemProps = ComponentProps< - typeof Field.Item, - Field.Item.State -> & { +export type FieldItemProps = ComponentProps< typeof Field.Item > & { children?: React.ReactNode; }; -export type FieldLabelProps = ComponentProps< - typeof Field.Label, - Field.Label.State -> & { +export type FieldLabelProps = ComponentProps< typeof Field.Label > & { /** * The label string, or the string and the element to associate it with. * @@ -50,7 +44,7 @@ export type FieldLabelProps = ComponentProps< }; export type FieldControlProps = Omit< - ComponentProps< typeof Field.Control, Field.Control.State >, + ComponentProps< typeof Field.Control >, 'defaultValue' > & { children?: Field.Control.Props[ 'children' ]; @@ -61,8 +55,7 @@ export type FieldControlProps = Omit< }; export type FieldDescriptionProps = ComponentProps< - typeof Field.Description, - Field.Description.State + typeof Field.Description > & { /** * The accessible description, associated using `aria-describedby`. diff --git a/packages/ui/src/form/primitives/fieldset/types.ts b/packages/ui/src/form/primitives/fieldset/types.ts index 84316341318cb5..6c182e2e076411 100644 --- a/packages/ui/src/form/primitives/fieldset/types.ts +++ b/packages/ui/src/form/primitives/fieldset/types.ts @@ -1,17 +1,11 @@ import type { Fieldset as _Fieldset } from '@base-ui/react'; import type { ComponentProps } from '../../../utils/types'; -export type FieldsetRootProps = ComponentProps< - typeof _Fieldset.Root, - _Fieldset.Root.State -> & { +export type FieldsetRootProps = ComponentProps< typeof _Fieldset.Root > & { children?: React.ReactNode; }; -export type FieldsetLegendProps = ComponentProps< - typeof _Fieldset.Legend, - _Fieldset.Legend.State -> & { +export type FieldsetLegendProps = ComponentProps< typeof _Fieldset.Legend > & { children?: React.ReactNode; }; diff --git a/packages/ui/src/form/primitives/input/types.ts b/packages/ui/src/form/primitives/input/types.ts index 7e288ef2be6ce3..20502d5d46428a 100644 --- a/packages/ui/src/form/primitives/input/types.ts +++ b/packages/ui/src/form/primitives/input/types.ts @@ -3,7 +3,7 @@ import type { InputLayoutProps } from '../input-layout/types'; import type { ComponentProps } from '../../../utils/types'; export type InputProps = Omit< - ComponentProps< typeof Input, Input.State >, + ComponentProps< typeof Input >, 'value' | 'defaultValue' | 'type' | 'disabled' | 'prefix' | 'size' > & Pick< InputLayoutProps, 'prefix' | 'suffix' > & { diff --git a/packages/ui/src/tabs/types.ts b/packages/ui/src/tabs/types.ts index 71d3807dd17884..a5d600fdb71646 100644 --- a/packages/ui/src/tabs/types.ts +++ b/packages/ui/src/tabs/types.ts @@ -2,20 +2,14 @@ import type { ReactNode } from 'react'; import type { Tabs } from '@base-ui/react/tabs'; import type { ComponentProps } from '../utils/types'; -export type TabRootProps = ComponentProps< - typeof Tabs.Root, - Tabs.Root.State -> & { +export type TabRootProps = ComponentProps< typeof Tabs.Root > & { /** * The content to be rendered inside the component. */ children?: ReactNode; }; -export type TabListProps = ComponentProps< - typeof Tabs.List, - Tabs.List.State -> & { +export type TabListProps = ComponentProps< typeof Tabs.List > & { /** * The content to be rendered inside the component. */ @@ -27,17 +21,14 @@ export type TabListProps = ComponentProps< density?: 'compact' | 'default'; }; -export type TabProps = ComponentProps< typeof Tabs.Tab, Tabs.Tab.State > & { +export type TabProps = ComponentProps< typeof Tabs.Tab > & { /** * The content to be rendered inside the component. */ children?: ReactNode; }; -export type TabPanelProps = ComponentProps< - typeof Tabs.Panel, - Tabs.Panel.State -> & { +export type TabPanelProps = ComponentProps< typeof Tabs.Panel > & { /** * The content to be rendered inside the component. */ diff --git a/packages/ui/src/utils/types.ts b/packages/ui/src/utils/types.ts index 55b7251943f1e3..8eb9d7d26989c7 100644 --- a/packages/ui/src/utils/types.ts +++ b/packages/ui/src/utils/types.ts @@ -1,20 +1,18 @@ -import type { - ElementType, - ComponentPropsWithoutRef, - HTMLAttributes, - ReactElement, - Ref, +import { + type ElementType, + type ComponentPropsWithoutRef, + type HTMLAttributes, + type Ref, } from 'react'; type HTMLAttributesWithRef< T extends ElementType = any > = HTMLAttributes< T > & { ref?: Ref< T > | undefined }; -type ComponentRenderFn< Props, State > = ( - props: Props, - state: State -) => ReactElement< unknown >; +type ComponentRenderFn< Props > = ( + props: Props +) => React.ReactElement< unknown >; -export type ComponentProps< E extends ElementType, S = unknown > = Omit< +export type ComponentProps< E extends ElementType > = Omit< ComponentPropsWithoutRef< E >, 'className' | 'children' | 'render' > & { @@ -28,6 +26,6 @@ export type ComponentProps< E extends ElementType, S = unknown > = Omit< * element, or a function that returns a React element. */ render?: - | ComponentRenderFn< HTMLAttributesWithRef, S > - | ReactElement< Record< string, unknown > >; + | ComponentRenderFn< HTMLAttributesWithRef > + | React.ReactElement< Record< string, unknown > >; }; From 3423e5061d73b199263914fd45e48462dbf57365 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 30 Jan 2026 15:16:35 +0100 Subject: [PATCH 27/38] Do not forward state in the render prop --- packages/ui/src/tabs/list.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/tabs/list.tsx b/packages/ui/src/tabs/list.tsx index 572395a8cf34e1..8926568d7c3990 100644 --- a/packages/ui/src/tabs/list.tsx +++ b/packages/ui/src/tabs/list.tsx @@ -105,7 +105,7 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( styles[ `has-${ density }-density` ], className ) } - render={ ( props, state ) => { + render={ ( props ) => { // Fallback to -1 to prevent browsers from making the tablist // tabbable when it is a scrolling container. const newProps = { @@ -116,7 +116,7 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( if ( isValidElement( render ) ) { return cloneElement( render, newProps ); } else if ( typeof render === 'function' ) { - return render( newProps, state ); + return render( newProps ); } return
; } } From 44d47cd0f5aa75cae76ac9574b499e8f61fcc1e6 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 30 Jan 2026 15:36:19 +0100 Subject: [PATCH 28/38] Update snippet --- packages/ui/src/tabs/stories/best-practices.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/tabs/stories/best-practices.mdx b/packages/ui/src/tabs/stories/best-practices.mdx index 1665e3b88d64d8..0ec6f78f28cece 100644 --- a/packages/ui/src/tabs/stories/best-practices.mdx +++ b/packages/ui/src/tabs/stories/best-practices.mdx @@ -42,6 +42,7 @@ const MyUncontrolledTabs = () => ( 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`. ```tsx +import { useState } from 'react'; import { Tabs } from '@wordpress/ui'; const [ selectedTabId, setSelectedTabId ] = useState< From d3c74cdb7228c7db836eda5e80a87c9484e86858 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 30 Jan 2026 15:39:50 +0100 Subject: [PATCH 29/38] Fix changelog --- packages/ui/CHANGELOG.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 42c545ad728f0c..8b27905637e8a6 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -2,8 +2,16 @@ ## Unreleased +### New Features + +- Add `Tabs` primitive ([#74652](https://github.com/WordPress/gutenberg/pull/74652)). + ## 0.6.0 (2026-01-29) +### New Features + +- Add `Select` primitive ([#74661](https://github.com/WordPress/gutenberg/pull/74661)). + ## 0.5.0 (2026-01-16) ### Breaking Changes @@ -20,5 +28,3 @@ - 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)). - Add `InputLayout` primitive ([#74313](https://github.com/WordPress/gutenberg/pull/74313)). - Add `Input` primitive ([#74615](https://github.com/WordPress/gutenberg/pull/74615)). -- Add `Select` primitive ([#74661](https://github.com/WordPress/gutenberg/pull/74661)). -- Add `Tabs` primitive ([#74652](https://github.com/WordPress/gutenberg/pull/74652)). From bd5c63e9b2c9f8b0376c96dd842d5f933e3840a1 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 30 Jan 2026 16:08:19 +0100 Subject: [PATCH 30/38] Refactor from `density="compact"` to `variant="minimal"` --- packages/ui/src/tabs/list.tsx | 4 ++-- packages/ui/src/tabs/stories/index.story.tsx | 4 ++-- packages/ui/src/tabs/style.module.css | 4 ++-- packages/ui/src/tabs/types.ts | 4 ++-- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/ui/src/tabs/list.tsx b/packages/ui/src/tabs/list.tsx index 8926568d7c3990..71a389f9c0731d 100644 --- a/packages/ui/src/tabs/list.tsx +++ b/packages/ui/src/tabs/list.tsx @@ -23,7 +23,7 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( function TabList( { children, - density = 'default', + variant = 'default', className, activateOnFocus, render, @@ -102,7 +102,7 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( styles.tablist, overflow.first && styles[ 'is-overflowing-first' ], overflow.last && styles[ 'is-overflowing-last' ], - styles[ `has-${ density }-density` ], + styles[ `is-${ variant }-variant` ], className ) } render={ ( props ) => { diff --git a/packages/ui/src/tabs/stories/index.story.tsx b/packages/ui/src/tabs/stories/index.story.tsx index dd933e3ba4f9d7..55441961c61d4e 100644 --- a/packages/ui/src/tabs/stories/index.story.tsx +++ b/packages/ui/src/tabs/stories/index.story.tsx @@ -55,12 +55,12 @@ export const Default: StoryObj< typeof Tabs.Root > = { }, }; -export const Compact: StoryObj< typeof Tabs.Root > = { +export const Minimal: StoryObj< typeof Tabs.Root > = { args: { ...Default.args, children: ( <> - + Tab 1 Tab 2 Tab 3 diff --git a/packages/ui/src/tabs/style.module.css b/packages/ui/src/tabs/style.module.css index 31fd661dddadd7..3cd953d6128f3f 100644 --- a/packages/ui/src/tabs/style.module.css +++ b/packages/ui/src/tabs/style.module.css @@ -39,7 +39,7 @@ linear-gradient(to left, var(--fade-gradient-composed)); } - &.has-compact-density { + &.is-minimal-variant { gap: 1rem; } } @@ -179,7 +179,7 @@ } } - .has-compact-density[data-orientation="horizontal"] & { + .is-minimal-variant[data-orientation="horizontal"] & { padding-inline: 0; &::after { diff --git a/packages/ui/src/tabs/types.ts b/packages/ui/src/tabs/types.ts index a5d600fdb71646..ba56a92f5a24d7 100644 --- a/packages/ui/src/tabs/types.ts +++ b/packages/ui/src/tabs/types.ts @@ -15,10 +15,10 @@ export type TabListProps = ComponentProps< typeof Tabs.List > & { */ children?: ReactNode; /** - * The visual density of the tab list. + * The visual variant of the tab list. * @default "default" */ - density?: 'compact' | 'default'; + variant?: 'minimal' | 'default'; }; export type TabProps = ComponentProps< typeof Tabs.Tab > & { From ef18423d547fdb2e028bd38c8a7dd9c2e7e1ce82 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 30 Jan 2026 16:08:38 +0100 Subject: [PATCH 31/38] Move useState inside render function in docs code snippet --- .../ui/src/tabs/stories/best-practices.mdx | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/packages/ui/src/tabs/stories/best-practices.mdx b/packages/ui/src/tabs/stories/best-practices.mdx index 0ec6f78f28cece..ad2439c29958c7 100644 --- a/packages/ui/src/tabs/stories/best-practices.mdx +++ b/packages/ui/src/tabs/stories/best-practices.mdx @@ -45,34 +45,36 @@ Tabs can also be used in a controlled mode, where the parent component uses the import { useState } from 'react'; import { Tabs } from '@wordpress/ui'; -const [ selectedTabId, setSelectedTabId ] = useState< - string | undefined | null ->( null ); - -const MyControlledTabs = () => ( - { - setSelectedTabId( newSelectedTabId ); - console.log( 'Selecting tab', newSelectedTabId ); - } } - > - - Tab 1 - Tab 2 - Tab 3 - - -

Selected tab: Tab 1

-
- -

Selected tab: Tab 2

-
- -

Selected tab: Tab 3

-
-
-); +const MyControlledTabs = () => { + const [ selectedTabId, setSelectedTabId ] = useState< + string | undefined | null + >( null ); + + return ( + { + setSelectedTabId( newSelectedTabId ); + console.log( 'Selecting tab', newSelectedTabId ); + } } + > + + Tab 1 + Tab 2 + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ ); +}; ``` ### Using `Tabs` with links From 078ca1635d8e2631fd27e4c159af1b1015075d66 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 30 Jan 2026 16:09:29 +0100 Subject: [PATCH 32/38] Follow Base UI renaming convention --- packages/ui/src/tabs/types.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/tabs/types.ts b/packages/ui/src/tabs/types.ts index ba56a92f5a24d7..6125b029a69a19 100644 --- a/packages/ui/src/tabs/types.ts +++ b/packages/ui/src/tabs/types.ts @@ -1,15 +1,15 @@ import type { ReactNode } from 'react'; -import type { Tabs } from '@base-ui/react/tabs'; +import type { Tabs as _Tabs } from '@base-ui/react/tabs'; import type { ComponentProps } from '../utils/types'; -export type TabRootProps = ComponentProps< typeof Tabs.Root > & { +export type TabRootProps = ComponentProps< typeof _Tabs.Root > & { /** * The content to be rendered inside the component. */ children?: ReactNode; }; -export type TabListProps = ComponentProps< typeof Tabs.List > & { +export type TabListProps = ComponentProps< typeof _Tabs.List > & { /** * The content to be rendered inside the component. */ @@ -21,14 +21,14 @@ export type TabListProps = ComponentProps< typeof Tabs.List > & { variant?: 'minimal' | 'default'; }; -export type TabProps = ComponentProps< typeof Tabs.Tab > & { +export type TabProps = ComponentProps< typeof _Tabs.Tab > & { /** * The content to be rendered inside the component. */ children?: ReactNode; }; -export type TabPanelProps = ComponentProps< typeof Tabs.Panel > & { +export type TabPanelProps = ComponentProps< typeof _Tabs.Panel > & { /** * The content to be rendered inside the component. */ From 921d96d81e2b64d158c9e45e67f58bbc1879467e Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 30 Jan 2026 16:14:56 +0100 Subject: [PATCH 33/38] Cleaner tabIndex fix in tests --- packages/ui/src/tabs/test/index.test.tsx | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/tabs/test/index.test.tsx b/packages/ui/src/tabs/test/index.test.tsx index 5dc79aabe8b7c3..7ef3765e947c7b 100644 --- a/packages/ui/src/tabs/test/index.test.tsx +++ b/packages/ui/src/tabs/test/index.test.tsx @@ -100,7 +100,11 @@ const UncontrolledTabs = ( { { tabObj.content } @@ -148,7 +152,11 @@ const ControlledTabs = ( { { tabObj.content } @@ -264,7 +272,11 @@ describe( 'Tabs', () => { { tabObj.content } From 71bb0202b63744503446921d1525458a7501a1f6 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 30 Jan 2026 23:00:00 +0100 Subject: [PATCH 34/38] Fix RTL edge fade when scrolling horizontally --- packages/ui/src/tabs/list.tsx | 24 +++++++++++++++++++----- packages/ui/src/tabs/style.module.css | 1 + 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/packages/ui/src/tabs/list.tsx b/packages/ui/src/tabs/list.tsx index 71a389f9c0731d..0eedd2d06877d2 100644 --- a/packages/ui/src/tabs/list.tsx +++ b/packages/ui/src/tabs/list.tsx @@ -11,7 +11,8 @@ import { useMergeRefs } from '@wordpress/compose'; import styles from './style.module.css'; import type { TabListProps } from './types'; -const DEFAULT_SCROLL_MARGIN = 0; +// Account for sub-pixel rounding errors. +const SCROLL_EPSILON = 1; /** * Groups the individual tab buttons. @@ -48,12 +49,25 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( const measureOverflow = () => { const { scrollWidth, clientWidth, scrollLeft } = listEl; + const maxScroll = Math.max( scrollWidth - clientWidth, 0 ); + const direction = + listEl.dir || + ( typeof window !== 'undefined' + ? window.getComputedStyle( listEl ).direction + : 'ltr' ); + const scrollFromStart = + direction === 'rtl' && scrollLeft < 0 + ? // In RTL layouts, scrollLeft is typically 0 at the visual "start" + // (right edge) and becomes negative toward the "end" (left edge). + // Normalize value for correct first/last detection logic. + -scrollLeft + : scrollLeft; + + // Use SCROLL_EPSILON to handle subpixel rendering differences. setOverflow( { - first: scrollLeft > DEFAULT_SCROLL_MARGIN, - last: - scrollLeft + clientWidth < - scrollWidth - DEFAULT_SCROLL_MARGIN, + first: scrollFromStart > SCROLL_EPSILON, + last: scrollFromStart < maxScroll - SCROLL_EPSILON, } ); }; diff --git a/packages/ui/src/tabs/style.module.css b/packages/ui/src/tabs/style.module.css index 3cd953d6128f3f..eaeb4c0c3bb47c 100644 --- a/packages/ui/src/tabs/style.module.css +++ b/packages/ui/src/tabs/style.module.css @@ -5,6 +5,7 @@ display: flex; align-items: stretch; overflow-inline: auto; + overscroll-behavior-inline: none; --direction-factor: 1; --direction-start: left; From 9966694cb28db08338f2a7a6a38bd8548b703d7d Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sat, 31 Jan 2026 00:16:09 +0100 Subject: [PATCH 35/38] Simplify tabIndex calculation for tablist --- packages/ui/src/tabs/list.tsx | 30 ++++++++---------------------- 1 file changed, 8 insertions(+), 22 deletions(-) diff --git a/packages/ui/src/tabs/list.tsx b/packages/ui/src/tabs/list.tsx index 0eedd2d06877d2..f90a3676d76d32 100644 --- a/packages/ui/src/tabs/list.tsx +++ b/packages/ui/src/tabs/list.tsx @@ -1,10 +1,4 @@ -import { - cloneElement, - forwardRef, - isValidElement, - useEffect, - useState, -} from '@wordpress/element'; +import { forwardRef, useEffect, useState } from '@wordpress/element'; import clsx from 'clsx'; import { Tabs as _Tabs } from '@base-ui/react/tabs'; import { useMergeRefs } from '@wordpress/compose'; @@ -36,9 +30,11 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( const [ overflow, setOverflow ] = useState< { first: boolean; last: boolean; + isScrolling: boolean; } >( { first: false, last: false, + isScrolling: false, } ); // Check if list is overflowing when it scrolls or resizes. @@ -68,6 +64,7 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( setOverflow( { first: scrollFromStart > SCROLL_EPSILON, last: scrollFromStart < maxScroll - SCROLL_EPSILON, + isScrolling: scrollWidth > clientWidth, } ); }; @@ -119,22 +116,11 @@ export const List = forwardRef< HTMLDivElement, TabListProps >( styles[ `is-${ variant }-variant` ], className ) } - render={ ( props ) => { - // Fallback to -1 to prevent browsers from making the tablist - // tabbable when it is a scrolling container. - const newProps = { - ...props, - tabIndex: props.tabIndex ?? -1, - }; - - if ( isValidElement( render ) ) { - return cloneElement( render, newProps ); - } else if ( typeof render === 'function' ) { - return render( newProps ); - } - return
; - } } { ...otherProps } + tabIndex={ + otherProps.tabIndex ?? + ( overflow.isScrolling ? -1 : undefined ) + } > { children } <_Tabs.Indicator className={ styles.indicator } /> From bdafa50695f000983cf500e61d34fb67e0d701ce Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sat, 31 Jan 2026 00:40:02 +0100 Subject: [PATCH 36/38] Do not use BEM --- packages/ui/src/tabs/style.module.css | 4 ++-- packages/ui/src/tabs/tab.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/tabs/style.module.css b/packages/ui/src/tabs/style.module.css index eaeb4c0c3bb47c..08c85cdc523e82 100644 --- a/packages/ui/src/tabs/style.module.css +++ b/packages/ui/src/tabs/style.module.css @@ -205,7 +205,7 @@ } } - .tab__children { + .tab-children { flex-grow: 1; display: flex; @@ -220,7 +220,7 @@ } } - .tab__chevron { + .tab-chevron { flex-shrink: 0; margin-inline-end: calc(var(--wpds-dimension-base) * -1); /* TODO: Use or create new control/interactive padding token */ diff --git a/packages/ui/src/tabs/tab.tsx b/packages/ui/src/tabs/tab.tsx index eca230738ae9f7..949d893536c29f 100644 --- a/packages/ui/src/tabs/tab.tsx +++ b/packages/ui/src/tabs/tab.tsx @@ -22,8 +22,8 @@ export const Tab = forwardRef< HTMLButtonElement, TabProps >( function Tab( className={ clsx( styles.tab, className ) } { ...otherProps } > - { children } - + { children } + ); } ); From 90741f00022deeb1e9891b809bf959cf500e114c Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sat, 31 Jan 2026 00:40:12 +0100 Subject: [PATCH 37/38] Simplify Storybook --- packages/ui/src/tabs/stories/best-practices.mdx | 3 +-- packages/ui/src/tabs/stories/index.story.tsx | 9 --------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/ui/src/tabs/stories/best-practices.mdx b/packages/ui/src/tabs/stories/best-practices.mdx index ad2439c29958c7..41e7b157e3534e 100644 --- a/packages/ui/src/tabs/stories/best-practices.mdx +++ b/packages/ui/src/tabs/stories/best-practices.mdx @@ -1,7 +1,6 @@ import { Meta } from '@storybook/addon-docs/blocks'; -import * as TabsStories from './index.story'; - + # Tabs diff --git a/packages/ui/src/tabs/stories/index.story.tsx b/packages/ui/src/tabs/stories/index.story.tsx index 55441961c61d4e..e588d6dd22e732 100644 --- a/packages/ui/src/tabs/stories/index.story.tsx +++ b/packages/ui/src/tabs/stories/index.story.tsx @@ -1,4 +1,3 @@ -import { fn } from 'storybook/test'; import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState, cloneElement } from '@wordpress/element'; import { link, more, wordpress } from '@wordpress/icons'; @@ -12,14 +11,6 @@ const meta: Meta< typeof Tabs.Root > = { 'Tabs.Tab': Tabs.Tab, 'Tabs.Panel': Tabs.Panel, }, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { canvas: { sourceState: 'shown' } }, - }, - args: { - onValueChange: fn(), - }, }; export default meta; From 5606714538d1efb9f3f253c952f0e68c89249fa7 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Sat, 31 Jan 2026 01:02:10 +0100 Subject: [PATCH 38/38] Remove unnecessary tabIndex check --- packages/ui/src/tabs/test/index.test.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/ui/src/tabs/test/index.test.tsx b/packages/ui/src/tabs/test/index.test.tsx index 7ef3765e947c7b..88c4bda488eebc 100644 --- a/packages/ui/src/tabs/test/index.test.tsx +++ b/packages/ui/src/tabs/test/index.test.tsx @@ -169,14 +169,6 @@ async function waitForComponentToBeInitializedWithSelectedTab( selectedTabName: string | undefined ) { if ( ! selectedTabName ) { - // Wait for the tablist to be tabbable as a mean to know - // that tabs has finished initializing. - await waitFor( () => - expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( - 'tabindex', - expect.stringMatching( /^(0|-1)$/ ) - ) - ); // No initially selected tabs or tabpanels. await waitFor( () => expect(