From 48890612047eba22cce4b576df15c5a027ac7423 Mon Sep 17 00:00:00 2001 From: Chad Chadbourne <13856531+chad1008@users.noreply.github.com> Date: Fri, 6 Oct 2023 13:46:46 -0400 Subject: [PATCH] Components: add `Tabs` (a composable `TabPanel` v2) (#53960) Co-authored-by: Marco Ciampini --- docs/manifest.json | 6 + packages/components/CHANGELOG.md | 4 + packages/components/src/tabs/README.md | 242 ++++ packages/components/src/tabs/context.ts | 13 + packages/components/src/tabs/index.tsx | 167 +++ .../src/tabs/stories/index.story.tsx | 352 ++++++ packages/components/src/tabs/styles.ts | 103 ++ packages/components/src/tabs/tab.tsx | 39 + packages/components/src/tabs/tablist.tsx | 40 + packages/components/src/tabs/tabpanel.tsx | 42 + packages/components/src/tabs/test/index.tsx | 1124 +++++++++++++++++ packages/components/src/tabs/types.ts | 142 +++ 12 files changed, 2274 insertions(+) create mode 100644 packages/components/src/tabs/README.md create mode 100644 packages/components/src/tabs/context.ts create mode 100644 packages/components/src/tabs/index.tsx create mode 100644 packages/components/src/tabs/stories/index.story.tsx create mode 100644 packages/components/src/tabs/styles.ts create mode 100644 packages/components/src/tabs/tab.tsx create mode 100644 packages/components/src/tabs/tablist.tsx create mode 100644 packages/components/src/tabs/tabpanel.tsx create mode 100644 packages/components/src/tabs/test/index.tsx create mode 100644 packages/components/src/tabs/types.ts diff --git a/docs/manifest.json b/docs/manifest.json index 4108da22296ef..447d5b0f4eeb8 100644 --- a/docs/manifest.json +++ b/docs/manifest.json @@ -1229,6 +1229,12 @@ "markdown_source": "../packages/components/src/tab-panel/README.md", "parent": "components" }, + { + "title": "Tabs", + "slug": "tabs", + "markdown_source": "../packages/components/src/tabs/README.md", + "parent": "components" + }, { "title": "TextControl", "slug": "text-control", diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 14166f8827ec8..2df99e7413ffa 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -42,6 +42,10 @@ - Ensure `@types/` dependencies used by final type files are included in the main dependency field ([#50231](https://github.com/WordPress/gutenberg/pull/50231)). - `Text`: Migrate to TypeScript. ([#54953](https://github.com/WordPress/gutenberg/pull/54953)). +### Experimental + +- Introduce `Tabs`, an experimental v2 of `TabPanel`: ([#53960](https://github.com/WordPress/gutenberg/pull/53960)). + ## 25.8.0 (2023-09-20) ### Enhancements diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md new file mode 100644 index 0000000000000..6907f385fda37 --- /dev/null +++ b/packages/components/src/tabs/README.md @@ -0,0 +1,242 @@ +# Tabs + +
+This feature is still experimental. “Experimental” means this is an early implementation subject to drastic and breaking changes. +
+ +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/). + +Tabs organizes content across different screens, data sets, and interactions. It has two sections: a list of tabs, and the view to show when tabs are chosen. + +## Development guidelines + +### Usage + +#### Uncontrolled Mode + +Tabs can be used in an uncontrolled mode, where the component manages its own state. In this mode, the `initialTabId` prop can be used to set the initially selected tab. If this prop is not set, the first tab will be selected by default. In addition, in most cases where the currently active tab becomes disabled or otherwise unavailable, uncontrolled mode will automatically fall back to selecting the first available tab. + +```jsx +import { Tabs } from '@wordpress/components'; + +const onSelect = ( tabName ) => { + console.log( 'Selecting tab', tabName ); +}; + +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 specifies the `selectedTabId` and the `onSelect` props to control tab selection. In this mode, the `initialTabId` prop will be ignored if it is provided. If the `selectedTabId` is `null`, no tab is selected. In this mode, if the currently selected tab becomes disabled or otherwise unavailable, the component will _not_ fall back to another available tab, leaving the controlling component in charge of implementing the desired logic. + +```jsx +import { Tabs } from '@wordpress/components'; + const [ selectedTabId, setSelectedTabId ] = useState< + string | undefined | null + >(); + +const onSelect = ( tabName ) => { + console.log( 'Selecting tab', tabName ); +}; + +const MyControlledTabs = () => ( + { + setSelectedTabId( selectedId ); + onSelect( selectedId ); + } } + > + + + Tab 1 + + + Tab 2 + + + Tab 3 + + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ ); +``` + +### Components and Sub-components + +Tabs is comprised of four individual components: +- `Tabs`: a wrapper component and context provider. It is responsible for managing the state of the tabs and rendering the `TabList` and `TabPanels`. +- `TabList`: a wrapper component for the `Tab` components. It is responsible for rendering the list of tabs. +- `Tab`: renders a single tab. The currently active tab receives default styling that can be overridden with CSS targeting [aria-selected="true"]. +- `TabPanel`: renders the content to display for a single tab once that tab is selected. + +#### Tabs + +##### Props + +###### `children`: `React.ReactNode` + +The children elements, which should be at least a `Tabs.Tablist` component and a series of `Tabs.TabPanel` components. + +- Required: Yes + +###### `selectOnMove`: `boolean` + +When `true`, the tab will be selected when receiving focus (automatic tab activation). When `false`, the tab will be selected only when clicked (manual tab activation). See the [official W3C docs](https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/) for more info. + +- Required: No +- Default: `true` + +###### `initialTabId`: `string` + +The id of the tab to be selected upon mounting of component. If this prop is not set, the first tab will be selected by default. The id provided will be internally prefixed with a unique instance ID to avoid collisions. + +_Note: this prop will be overridden by the `selectedTabId` prop if it is provided. (Controlled Mode)_ + +- Required: No + +###### `onSelect`: `( ( selectedId: string | null | undefined ) => void )` + +The function called when a tab has been selected. It is passed the selected tab's ID as an argument. + +- Required: No +- Default: `noop` + +###### `orientation`: `horizontal | vertical` + +The orientation of the `tablist` (`vertical` or `horizontal`) + +- Required: No +- Default: `horizontal` + +###### `selectedTabId`: `string | null` + +The ID of the tab to display. This id is prepended with the `Tabs` instanceId internally. +If left `undefined`, the component assumes it is being used in uncontrolled mode. Consequently, any value different than `undefined` will set the component in `controlled` mode. When in controlled mode, the `null` value will result in no tab being selected. + +- Required: No + +#### TabList + +##### Props + +###### `children`: `React.ReactNode` + +The children elements, which should be a series of `Tabs.TabPanel` components. + +- Required: No + +###### `className`: `string` + +The class name to apply to the tablist. + +- Required: No +- Default: '' + +###### `style`: `React.CSSProperties` + +Custom CSS styles for the tablist. + +- Required: No + +#### Tab + +##### Props + +###### `id`: `string` + +The id of the tab, which is prepended with the `Tabs` instance ID. + +- Required: Yes + +###### `style`: `React.CSSProperties` + +Custom CSS styles for the tab. + +- Required: No + +###### `children`: `React.ReactNode` + +The children elements, generally the text to display on the tab. + +- Required: No + +###### `className`: `string` + +The class name to apply to the tab. + +- Required: No + +###### `disabled`: `boolean` + +Determines if the tab button should be disabled. + +- Required: No +- Default: `false` + +###### `render`: `React.ReactNode` + +The type of component to render the tab button as. If this prop is not provided, the tab button will be rendered as a `button` element. + +- Required: No + +#### TabPanel + +##### Props + +###### `children`: `React.ReactNode` + +The children elements, generally the content to display on the tabpanel. + +- Required: No + +###### `id`: `string` + +The id of the tabpanel, which is combined with the `Tabs` instance ID and the suffix `-view` + +- Required: Yes + +###### `className`: `string` + +The class name to apply to the tabpanel. + +- Required: No + +###### `style`: `React.CSSProperties` + +Custom CSS styles for the tab. + +- Required: No diff --git a/packages/components/src/tabs/context.ts b/packages/components/src/tabs/context.ts new file mode 100644 index 0000000000000..cc6184d827138 --- /dev/null +++ b/packages/components/src/tabs/context.ts @@ -0,0 +1,13 @@ +/** + * WordPress dependencies + */ +import { createContext, useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabsContextProps } from './types'; + +export const TabsContext = createContext< TabsContextProps >( undefined ); + +export const useTabsContext = () => useContext( TabsContext ); diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx new file mode 100644 index 0000000000000..54f547ad2f52d --- /dev/null +++ b/packages/components/src/tabs/index.tsx @@ -0,0 +1,167 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import * as Ariakit from '@ariakit/react'; + +/** + * WordPress dependencies + */ +import { useInstanceId } from '@wordpress/compose'; +import { useEffect, useLayoutEffect, useRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabsProps } from './types'; +import { TabsContext } from './context'; +import { Tab } from './tab'; +import { TabList } from './tablist'; +import { TabPanel } from './tabpanel'; + +function Tabs( { + selectOnMove = true, + initialTabId, + orientation = 'horizontal', + onSelect, + children, + selectedTabId, +}: TabsProps ) { + const instanceId = useInstanceId( Tabs, 'tabs' ); + const store = Ariakit.useTabStore( { + selectOnMove, + orientation, + defaultSelectedId: initialTabId && `${ instanceId }-${ initialTabId }`, + setSelectedId: ( selectedId ) => { + const strippedDownId = + typeof selectedId === 'string' + ? selectedId.replace( `${ instanceId }-`, '' ) + : selectedId; + onSelect?.( strippedDownId ); + }, + selectedId: selectedTabId && `${ instanceId }-${ selectedTabId }`, + } ); + + const isControlled = selectedTabId !== undefined; + + const { items, selectedId } = store.useState(); + const { setSelectedId } = store; + + // Keep track of whether tabs have been populated. This is used to prevent + // certain effects from firing too early while tab data and relevant + // variables are undefined during the initial render. + const tabsHavePopulated = useRef( false ); + if ( items.length > 0 ) { + tabsHavePopulated.current = true; + } + + const selectedTab = items.find( ( item ) => item.id === selectedId ); + const firstEnabledTab = items.find( ( item ) => { + // Ariakit internally refers to disabled tabs as `dimmed`. + return ! item.dimmed; + } ); + const initialTab = items.find( + ( item ) => item.id === `${ instanceId }-${ initialTabId }` + ); + + // Handle selecting the initial tab. + useLayoutEffect( () => { + if ( isControlled ) { + return; + } + + // Wait for the denoted initial tab to be declared before making a + // selection. This ensures that if a tab is declared lazily it can + // still receive initial selection, as well as ensuring no tab is + // selected if an invalid `initialTabId` is provided. + if ( initialTabId && ! initialTab ) { + return; + } + + // If the currently selected tab is missing (i.e. removed from the DOM), + // fall back to the initial tab or the first enabled tab if there is + // one. Otherwise, no tab should be selected. + if ( ! items.find( ( item ) => item.id === selectedId ) ) { + if ( initialTab && ! initialTab.dimmed ) { + setSelectedId( initialTab?.id ); + return; + } + + if ( firstEnabledTab ) { + setSelectedId( firstEnabledTab.id ); + } else if ( tabsHavePopulated.current ) { + setSelectedId( null ); + } + } + }, [ + firstEnabledTab, + initialTab, + initialTabId, + isControlled, + items, + selectedId, + setSelectedId, + ] ); + + // Handle the currently selected tab becoming disabled. + useEffect( () => { + if ( ! selectedTab?.dimmed ) { + return; + } + + // In controlled mode, we trust that disabling tabs is done + // intentionally, and don't select a new tab automatically. + if ( isControlled ) { + setSelectedId( null ); + return; + } + + // If the currently selected tab becomes disabled, fall back to the + // `initialTabId` if possible. Otherwise select the first + // enabled tab (if there is one). + if ( initialTab && ! initialTab.dimmed ) { + setSelectedId( initialTab.id ); + return; + } + + if ( firstEnabledTab ) { + setSelectedId( firstEnabledTab.id ); + } + }, [ + firstEnabledTab, + initialTab, + isControlled, + selectedTab?.dimmed, + setSelectedId, + ] ); + + // Clear `selectedId` if the active tab is removed from the DOM in controlled mode. + useEffect( () => { + if ( ! isControlled ) { + return; + } + + // Once the tabs have populated, if the `selectedTabId` still can't be + // found, clear the selection. + if ( tabsHavePopulated.current && !! selectedTabId && ! selectedTab ) { + setSelectedId( null ); + } + }, [ + isControlled, + selectedId, + selectedTab, + selectedTabId, + setSelectedId, + ] ); + + return ( + + { children } + + ); +} + +Tabs.TabList = TabList; +Tabs.Tab = Tab; +Tabs.TabPanel = TabPanel; +export default Tabs; diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx new file mode 100644 index 0000000000000..3b6ba022f6d91 --- /dev/null +++ b/packages/components/src/tabs/stories/index.story.tsx @@ -0,0 +1,352 @@ +/** + * External dependencies + */ +import type { Meta, StoryFn } from '@storybook/react'; + +/** + * WordPress dependencies + */ +import { wordpress, more, link } from '@wordpress/icons'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Tabs from '..'; +import { Slot, Fill, Provider as SlotFillProvider } from '../../slot-fill'; +import DropdownMenu from '../../dropdown-menu'; +import Button from '../../button'; + +const meta: Meta< typeof Tabs > = { + title: 'Components (Experimental)/Tabs', + component: Tabs, + parameters: { + actions: { argTypesRegex: '^on.*' }, + controls: { expanded: true }, + docs: { canvas: { sourceState: 'shown' } }, + }, +}; +export default meta; + +const Template: StoryFn< typeof Tabs > = ( props ) => { + return ( + + + Tab 1 + Tab 2 + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ ); +}; + +export const Default = Template.bind( {} ); + +const DisabledTabTemplate: StoryFn< typeof Tabs > = ( props ) => { + return ( + + + + Tab 1 + + Tab 2 + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ ); +}; +export const DisabledTab = DisabledTabTemplate.bind( {} ); + +const WithTabIconsAndTooltipsTemplate: StoryFn< typeof Tabs > = ( props ) => { + return ( + + + + } + /> + + } + /> + + } + /> + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ ); +}; +export const WithTabIconsAndTooltips = WithTabIconsAndTooltipsTemplate.bind( + {} +); + +export const ManualActivation = Template.bind( {} ); +ManualActivation.args = { + selectOnMove: false, +}; + +const UsingSlotFillTemplate: StoryFn< typeof Tabs > = ( props ) => { + return ( + + + + Tab 1 + Tab 2 + Tab 3 + + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+
+
+

other stuff

+

other stuff

+

this is fun!

+

other stuff

+ +
+
+ ); +}; +export const UsingSlotFill = UsingSlotFillTemplate.bind( {} ); +UsingSlotFill.storyName = 'Using SlotFill'; + +const CloseButtonTemplate: StoryFn< typeof Tabs > = ( props ) => { + const [ isOpen, setIsOpen ] = useState( true ); + + return ( + <> + { isOpen ? ( +
+ +
+ + Tab 1 + Tab 2 + Tab 3 + + +
+ +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+
+ ) : ( + + ) } + + ); +}; +export const InsertCustomElements = CloseButtonTemplate.bind( {} ); + +const ControlledModeTemplate: StoryFn< typeof Tabs > = ( props ) => { + const [ selectedTabId, setSelectedTabId ] = useState< + string | undefined | null + >( props.selectedTabId ); + + return ( + <> + { + setSelectedTabId( selectedId ); + props.onSelect?.( selectedId ); + } } + > + + Tab 1 + + Tab 2 + + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ { +
+

Select a tab:

+ setSelectedTabId( 'tab1' ), + title: 'Tab 1', + isActive: selectedTabId === 'tab1', + }, + { + onClick: () => setSelectedTabId( 'tab2' ), + title: 'Tab 2', + isActive: selectedTabId === 'tab2', + }, + { + onClick: () => setSelectedTabId( 'tab3' ), + title: 'Tab 3', + isActive: selectedTabId === 'tab3', + }, + ] } + label="Choose a tab. The power is yours." + /> +
+ } + + ); +}; + +export const ControlledMode = ControlledModeTemplate.bind( {} ); +ControlledMode.args = { + selectedTabId: 'tab3', +}; + +const TabBecomesDisabledTemplate: StoryFn< typeof Tabs > = ( props ) => { + const [ disableTab2, setDisableTab2 ] = useState( false ); + + return ( + <> + + + + Tab 1 + + Tab 2 + + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ + ); +}; +export const TabBecomesDisabled = TabBecomesDisabledTemplate.bind( {} ); + +const TabGetsRemovedTemplate: StoryFn< typeof Tabs > = ( props ) => { + const [ removeTab1, setRemoveTab1 ] = useState( false ); + + return ( + <> + + + + { ! removeTab1 && Tab 1 } + Tab 2 + Tab 3 + + +

Selected tab: Tab 1

+
+ +

Selected tab: Tab 2

+
+ +

Selected tab: Tab 3

+
+
+ + ); +}; +export const TabGetsRemoved = TabGetsRemovedTemplate.bind( {} ); diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts new file mode 100644 index 0000000000000..091ba608fb6ec --- /dev/null +++ b/packages/components/src/tabs/styles.ts @@ -0,0 +1,103 @@ +/** + * External dependencies + */ +import styled from '@emotion/styled'; +// eslint-disable-next-line no-restricted-imports +import * as Ariakit from '@ariakit/react'; + +/** + * Internal dependencies + */ +import { COLORS } from '../utils'; +import { space } from '../utils/space'; +import { reduceMotion } from '../utils/reduce-motion'; + +export const TabListWrapper = styled.div` + display: flex; + align-items: stretch; + flex-direction: row; + &[aria-orientation='vertical'] { + flex-direction: column; + } +`; + +export const Tab = styled( Ariakit.Tab )` + && { + position: relative; + border-radius: 0; + height: ${ space( 12 ) }; + background: transparent; + border: none; + box-shadow: none; + cursor: pointer; + padding: 3px ${ space( 4 ) }; // Use padding to offset the [aria-selected="true"] border, this benefits Windows High Contrast mode + margin-left: 0; + font-weight: 500; + + &[aria-disabled='true'] { + cursor: default; + opacity: 0.3; + } + + &:focus:not( :disabled ) { + position: relative; + box-shadow: none; + outline: none; + } + + // Tab indicator + &::after { + content: ''; + position: absolute; + right: 0; + bottom: 0; + left: 0; + pointer-events: none; + + // Draw the indicator. + background: ${ COLORS.theme.accent }; + height: calc( 0 * var( --wp-admin-border-width-focus ) ); + border-radius: 0; + + // Animation + transition: all 0.1s linear; + ${ reduceMotion( 'transition' ) }; + } + + // Active. + &[aria-selected='true']::after { + height: calc( 1 * var( --wp-admin-border-width-focus ) ); + + // Windows high contrast mode. + outline: 2px solid transparent; + outline-offset: -1px; + } + + // Focus. + &::before { + content: ''; + position: absolute; + top: ${ space( 3 ) }; + right: ${ space( 3 ) }; + bottom: ${ space( 3 ) }; + left: ${ space( 3 ) }; + pointer-events: none; + + // Draw the indicator. + box-shadow: 0 0 0 0 transparent; + border-radius: 2px; + + // Animation + transition: all 0.1s linear; + ${ reduceMotion( 'transition' ) }; + } + + &:focus-visible::before { + box-shadow: 0 0 0 var( --wp-admin-border-width-focus ) + ${ COLORS.theme.accent }; + + // Windows high contrast mode. + outline: 2px solid transparent; + } + } +`; diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx new file mode 100644 index 0000000000000..75b3df1c1ba01 --- /dev/null +++ b/packages/components/src/tabs/tab.tsx @@ -0,0 +1,39 @@ +/** + * WordPress dependencies + */ + +import { useContext, forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabProps } from './types'; +import warning from '@wordpress/warning'; +import { TabsContext } from './context'; +import { Tab as StyledTab } from './styles'; + +export const Tab = forwardRef< HTMLButtonElement, TabProps >( function Tab( + { children, id, className, disabled, render, style }, + ref +) { + const context = useContext( TabsContext ); + if ( ! context ) { + warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); + return null; + } + const { store, instanceId } = context; + const instancedTabId = `${ instanceId }-${ id }`; + return ( + + { children } + + ); +} ); diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx new file mode 100644 index 0000000000000..02255fefd2082 --- /dev/null +++ b/packages/components/src/tabs/tablist.tsx @@ -0,0 +1,40 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import * as Ariakit from '@ariakit/react'; + +/** + * WordPress dependencies + */ +import warning from '@wordpress/warning'; +import { forwardRef } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabListProps } from './types'; +import { useTabsContext } from './context'; +import { TabListWrapper } from './styles'; + +export const TabList = forwardRef< HTMLDivElement, TabListProps >( + function TabList( { children, className, style }, ref ) { + const context = useTabsContext(); + if ( ! context ) { + warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' ); + return null; + } + const { store } = context; + return ( + } + > + { children } + + ); + } +); diff --git a/packages/components/src/tabs/tabpanel.tsx b/packages/components/src/tabs/tabpanel.tsx new file mode 100644 index 0000000000000..fb62fc9191233 --- /dev/null +++ b/packages/components/src/tabs/tabpanel.tsx @@ -0,0 +1,42 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import * as Ariakit from '@ariakit/react'; + +/** + * WordPress dependencies + */ + +import { forwardRef, useContext } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import type { TabPanelProps } from './types'; + +import warning from '@wordpress/warning'; +import { TabsContext } from './context'; + +export const TabPanel = forwardRef< HTMLDivElement, TabPanelProps >( + function TabPanel( { children, id, className, style }, ref ) { + const context = useContext( TabsContext ); + if ( ! context ) { + warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' ); + return null; + } + const { store, instanceId } = context; + + return ( + + { children } + + ); + } +); diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx new file mode 100644 index 0000000000000..1b437966239a0 --- /dev/null +++ b/packages/components/src/tabs/test/index.tsx @@ -0,0 +1,1124 @@ +/** + * External dependencies + */ +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +/** + * WordPress dependencies + */ +import { wordpress, category, media } from '@wordpress/icons'; +import { useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Tabs from '..'; +import type { TabsProps } from '../types'; +import type { IconType } from '../../icon'; + +type Tab = { + id: string; + title: string; + content: React.ReactNode; + tab: { + className?: string; + icon?: IconType; + disabled?: boolean; + }; +}; + +const TABS: Tab[] = [ + { + id: 'alpha', + title: 'Alpha', + content: 'Selected tab: Alpha', + tab: { className: 'alpha-class', icon: wordpress }, + }, + { + id: 'beta', + title: 'Beta', + content: 'Selected tab: Beta', + tab: { className: 'beta-class', icon: category }, + }, + { + id: 'gamma', + title: 'Gamma', + content: 'Selected tab: Gamma', + tab: { className: 'gamma-class', icon: media }, + }, +]; + +const TABS_WITH_DELTA: Tab[] = [ + ...TABS, + { + id: 'delta', + title: 'Delta', + content: 'Selected tab: Delta', + tab: { className: 'delta-class', icon: media }, + }, +]; + +const UncontrolledTabs = ( { + tabs, + showTabIcons = false, + ...props +}: Omit< TabsProps, 'children' > & { + tabs: Tab[]; + showTabIcons?: boolean; +} ) => { + return ( + + + { tabs.map( ( tabObj ) => ( + + { showTabIcons ? null : tabObj.title } + + ) ) } + + { tabs.map( ( tabObj ) => ( + + { tabObj.content } + + ) ) } + + ); +}; + +const ControlledTabs = ( { + tabs, + showTabIcons = false, + ...props +}: Omit< TabsProps, 'children' > & { + tabs: Tab[]; + showTabIcons?: boolean; +} ) => { + const [ selectedTabId, setSelectedTabId ] = useState< + string | undefined | null + >( props.selectedTabId ); + + return ( + { + setSelectedTabId( selectedId ); + props.onSelect?.( selectedId ); + } } + > + + { tabs.map( ( tabObj ) => ( + + { showTabIcons ? null : tabObj.title } + + ) ) } + + { tabs.map( ( tabObj ) => ( + + { tabObj.content } + + ) ) } + + ); +}; + +const getSelectedTab = async () => + await screen.findByRole( 'tab', { selected: true } ); + +let originalGetClientRects: () => DOMRectList; + +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' ]; + }; + } ); + + afterAll( () => { + window.HTMLElement.prototype.getClientRects = originalGetClientRects; + } ); + + describe( 'Accessibility and semantics', () => { + it( 'should use the correct aria attributes', async () => { + render( ); + + const tabList = screen.getByRole( 'tablist' ); + const allTabs = screen.getAllByRole( 'tab' ); + const selectedTabPanel = await screen.findByRole( 'tabpanel' ); + + expect( tabList ).toBeVisible(); + expect( tabList ).toHaveAttribute( + 'aria-orientation', + 'horizontal' + ); + + expect( allTabs ).toHaveLength( TABS.length ); + + // The selected `tab` aria-controls the active `tabpanel`, + // which is `aria-labelledby` the selected `tab`. + expect( selectedTabPanel ).toBeVisible(); + expect( allTabs[ 0 ] ).toHaveAttribute( + 'aria-controls', + selectedTabPanel.getAttribute( 'id' ) + ); + expect( selectedTabPanel ).toHaveAttribute( + 'aria-labelledby', + allTabs[ 0 ].getAttribute( 'id' ) + ); + } ); + } ); + + describe( 'Tab Attributes', () => { + it( "should apply the tab's `className` to the tab button", async () => { + render( ); + + 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( 'Tab Activation', () => { + it( 'defaults to automatic tab activation (pointer clicks)', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + + ); + + // Alpha is the initially selected tab + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Click on Beta, make sure beta is the selected tab + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( + screen.getByRole( 'tabpanel', { name: 'Beta' } ) + ).toBeInTheDocument(); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Click on Alpha, make sure beta is the selected tab + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'defaults to automatic tab activation (arrow keys)', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + + ); + + // onSelect gets called on the initial render. It should be called + // with the first enabled tab, which is alpha. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Tab to focus the tablist. Make sure alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Navigate forward with arrow keys and make sure the Beta tab is + // selected automatically. + await user.keyboard( '[ArrowRight]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowLeft]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'wraps around the last/first tab when using arrow keys', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure Alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Navigate backwards with arrow keys and make sure that the Gamma tab + // (the last tab) is selected automatically. + await user.keyboard( '[ArrowLeft]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + // Navigate forward with arrow keys. Make sure alpha (the first tab) is + // selected automatically. + await user.keyboard( '[ArrowRight]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'should not move tab selection when pressing the up/down arrow keys, unless the orientation is changed to `vertical`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + // onSelect gets called on the initial render. It should be called + // with the first enabled tab, which is alpha. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Tab to focus the tablist. Make sure alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Press the arrow up key, nothing happens. + await user.keyboard( '[ArrowUp]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Press the arrow down key, nothing happens + await user.keyboard( '[ArrowDown]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Change orientation to `vertical`. When the orientation is vertical, + // left/right arrow keys are replaced by up/down arrow keys. + rerender( + + ); + + expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( + 'aria-orientation', + 'vertical' + ); + + // Make sure alpha is still focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Navigate forward with arrow keys and make sure the Beta tab is + // selected automatically. + await user.keyboard( '[ArrowDown]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowUp]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowUp]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowDown]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + } ); + + it( 'should move focus on a tab even if disabled with arrow key, but not with pointer clicks', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const TABS_WITH_DELTA_DISABLED = TABS_WITH_DELTA.map( ( tabObj ) => + tabObj.id === 'delta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + render( + + ); + + // onSelect gets called on the initial render. It should be called + // with the first enabled tab, which is alpha. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Tab to focus the tablist. Make sure Alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + // Confirm onSelect has not been re-called + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Press the right arrow key three times. Since the delta tab is disabled: + // - it won't be selected. The gamma tab will be selected instead, since + // it was the tab that was last selected before delta. Therefore, the + // `mockOnSelect` function gets called only twice (and not three times) + // - it will receive focus, when using arrow keys + await user.keyboard( '[ArrowRight][ArrowRight][ArrowRight]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( + screen.getByRole( 'tab', { name: 'Delta' } ) + ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + // Navigate backwards with arrow keys. The gamma tab receives focus. + // The `mockOnSelect` callback doesn't fire, since the gamma tab was + // already selected. + await user.keyboard( '[ArrowLeft]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + + // Click on the disabled tab. Compared to using arrow keys to move the + // focus, disabled tabs ignore pointer clicks — and therefore, they don't + // receive focus, nor they cause the `mockOnSelect` function to fire. + await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + } ); + + it( 'should not focus the next tab when the Tab key is pressed', async () => { + const user = userEvent.setup(); + + render( ); + + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '[Tab]' ); + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + + // Because all other tabs should have `tabindex=-1`, pressing Tab + // should NOT move the focus to the next tab, which is Beta. + await user.keyboard( '[Tab]' ); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).not.toHaveFocus(); + } ); + + it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + + ); + + // onSelect gets called on the initial render. It should be called + // with the first enabled tab, which is alpha. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Click on Alpha and make sure it is selected. + // onSelect shouldn't fire since the selected tab didn't change. + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Navigate forward with arrow keys. Make sure Beta is focused, but + // that the tab selection happens only when pressing the spacebar + // or enter key. onSelect shouldn't fire since the selected tab + // didn't change. + await user.keyboard( '[ArrowRight]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).toHaveFocus(); + + await user.keyboard( '[Enter]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Navigate forward with arrow keys. Make sure Gamma (last tab) is + // focused, but that tab selection happens only when pressing the + // spacebar or enter key. onSelect shouldn't fire since the selected + // tab didn't change. + await user.keyboard( '[ArrowRight]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( + screen.getByRole( 'tab', { name: 'Gamma' } ) + ).toHaveFocus(); + + await user.keyboard( '[Space]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + } ); + } ); + describe( 'Uncontrolled mode', () => { + describe( 'Without `initialTabId` prop', () => { + it( 'should render first tab', async () => { + render( ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + } ); + it( 'should fall back to first enabled tab if the active tab is removed', async () => { + const { rerender } = render( + + ); + + // Remove first item from `TABS` array + rerender( ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + it( 'should not load any tab if the active tab is removed and there are no enabled tabs', async () => { + const TABS_WITH_BETA_GAMMA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id !== 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + const { rerender } = render( + + ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + + // Remove alpha + rerender( + + ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } ); + } ); + + describe( 'With `initialTabId`', () => { + it( 'should render the tab set by `initialTabId` prop', async () => { + render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should not select a tab when `initialTabId` does not match any known tab', () => { + render( + + ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } ); + it( 'should not change tabs when initialTabId is changed', async () => { + const { rerender } = render( + + ); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should fall back to the tab associated to `initialTabId` if the currently active tab is removed', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + await user.click( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + + it( 'should fall back to the tab associated to `initialTabId` if the currently active tab becomes disabled', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + await user.click( + screen.getByRole( 'tab', { name: 'Alpha' } ) + ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id === 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + + it( 'should have no active tabs when the tab associated to `initialTabId` is removed while being the active tab', async () => { + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + // Remove gamma + rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + // No tab should be selected i.e. it doesn't fall back to first tab. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } ); + + it( 'waits for the tab with the `initialTabId` to be present in the `tabs` array before selecting it', async () => { + const { rerender } = render( + + ); + + // There should be no selected tab yet. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Delta' ); + } ); + } ); + + describe( 'Disabled tab', () => { + it( 'should disable the tab when `disabled` is `true`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const TABS_WITH_DELTA_DISABLED = TABS_WITH_DELTA.map( + ( tabObj ) => + tabObj.id === 'delta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + render( + + ); + + expect( + screen.getByRole( 'tab', { name: 'Delta' } ) + ).toHaveAttribute( 'aria-disabled', 'true' ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // onSelect should not be called since the disabled tab is + // highlighted, but not selected. + await user.keyboard( '[Tab]' ); + await user.keyboard( '[ArrowLeft]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Delta (which is disabled) has focus + expect( + screen.getByRole( 'tab', { name: 'Delta' } ) + ).toHaveFocus(); + + // Alpha retains the selection, even if it's not focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + } ); + + it( 'should select first enabled tab when the initial tab is disabled', async () => { + const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id === 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + const { rerender } = render( + + ); + + // As alpha (first tab) is disabled, + // the first enabled tab should be beta. + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Re-enable all tabs + rerender( ); + + // Even if the initial tab becomes enabled again, the selected + // tab doesn't change. + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should select first enabled tab when the tab associated to `initialTabId` is disabled', async () => { + const TABS_ONLY_GAMMA_ENABLED = TABS.map( ( tabObj ) => + tabObj.id !== 'gamma' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + const { rerender } = render( + + ); + + // As alpha (first tab), and beta (the initial tab), are both + // disabled the first enabled tab should be gamma. + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + // Re-enable all tabs + rerender( + + ); + + // Even if the initial tab becomes enabled again, the selected tab doesn't + // change. + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + + it( 'should select the first enabled tab when the selected tab becomes disabled', async () => { + const mockOnSelect = jest.fn(); + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + const TABS_WITH_ALPHA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id === 'alpha' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + // Disable alpha + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + // Re-enable all tabs + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + } ); + + it( 'should select the first enabled tab when the tab associated to `initialTabId` becomes disabled while being the active tab', async () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + const TABS_WITH_GAMMA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id === 'gamma' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + // Disable gamma + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Re-enable all tabs + rerender( + + ); + + // Confirm that alpha is still selected, and that onSelect has + // not been called again. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + } ); + + describe( 'Controlled mode', () => { + it( 'should render the tab specified by the `selectedTabId` prop', async () => { + render( ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( + await screen.findByRole( 'tabpanel', { name: 'Beta' } ) + ).toBeInTheDocument(); + } ); + it( 'should render the specified `selectedTabId`, and ignore the `initialTabId` prop', async () => { + render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + it( 'should not render any tab if `selectedTabId` does not match any known tab', async () => { + render( + + ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + // `waitFor` is needed here to prevent testing library from + // throwing a 'not wrapped in `act()`' error. + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + // No tabpanel should be rendered either + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); + } ); + it( 'should not render any tab if the active tab is removed', async () => { + const { rerender } = render( + + ); + + // Remove beta + rerender( + tab.id !== 'beta' ) } + selectedTabId="beta" + /> + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + // `waitFor` is needed here to prevent testing library from + // throwing a 'not wrapped in `act()`' error. + await waitFor( () => + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument() + ); + // No tabpanel should be rendered either + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); + + // Restore beta + rerender( ); + + // No tab should be selected i.e. it doesn't reselect the previously + // removed tab. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); + } ); + + describe( 'Disabled tab', () => { + it( 'should not render any tab if `selectedTabId` refers to a disabled tab', async () => { + const TABS_WITH_DELTA_WITH_BETA_DISABLED = TABS_WITH_DELTA.map( + ( tabObj ) => + tabObj.id === 'beta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + render( + + ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + await waitFor( () => { + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + } ); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } ); + it( 'should not render any tab when the selected tab becomes disabled', async () => { + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) => + tabObj.id === 'beta' + ? { + ...tabObj, + tab: { + ...tabObj.tab, + disabled: true, + }, + } + : tabObj + ); + + rerender( + + ); + // No tab should be selected i.e. it doesn't fall back to first tab. + // `waitFor` is needed here to prevent testing library from + // throwing a 'not wrapped in `act()`' error. + await waitFor( () => { + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + } ); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + + // re-enable all tabs + rerender( + + ); + + // If the previously selected tab is reenabled, it should not + // be reselected. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + // No tabpanel should be rendered either + expect( + screen.queryByRole( 'tabpanel' ) + ).not.toBeInTheDocument(); + } ); + } ); + } ); +} ); diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts new file mode 100644 index 0000000000000..88e25eb5a3863 --- /dev/null +++ b/packages/components/src/tabs/types.ts @@ -0,0 +1,142 @@ +/** + * External dependencies + */ +// eslint-disable-next-line no-restricted-imports +import type * as Ariakit from '@ariakit/react'; + +/** + * Internal dependencies + */ +import type { IconType } from '../icon'; + +export type TabsContextProps = + | { + /** + * The tabStore object returned by Ariakit's `useTabStore` hook. + */ + store: Ariakit.TabStore; + /** + * The unique id string for this instance of the Tabs component. + */ + instanceId: string; + } + | undefined; + +export type TabsProps = { + /** + * The children elements, which should be at least a + * `Tabs.Tablist` component and a series of `Tabs.TabPanel` + * components. + */ + children: React.ReactNode; + /** + * When `true`, the tab will be selected when receiving focus (automatic tab + * activation). When `false`, the tab will be selected only when clicked + * (manual tab activation). See the official W3C docs for more info. + * + * @default true + * + * @see https://www.w3.org/WAI/ARIA/apg/patterns/tabpanel/ + */ + selectOnMove?: boolean; + /** + * The id of the tab to be selected upon mounting of component. + * If this prop is not set, the first tab will be selected by default. + * The id provided will be internally prefixed with the + * `TabsContextProps.instanceId`. + * + * Note: this prop will be overridden by the `selectedTabId` prop if it is + * provided. (Controlled Mode) + */ + initialTabId?: string; + /** + * The function called when a tab has been selected. + * It is passed the id of the newly selected tab as an argument. + */ + onSelect?: ( selectedId: string | null | undefined ) => void; + + /** + * The orientation of the tablist. + * + * @default `horizontal` + */ + orientation?: 'horizontal' | 'vertical'; + /** + * The Id of the tab to display. This id is prepended with the `Tabs` + * instanceId internally. + * + * If left `undefined`, the component assumes it is being used in + * uncontrolled mode. Consequently, any value different than `undefined` + * will set the component in `controlled` mode. + * When in controlled mode, the `null` value will result in no tab being selected. + */ + selectedTabId?: string | null; +}; + +export type TabListProps = { + /** + * The children elements, which should be a series of `Tabs.TabPanel` components. + */ + children?: React.ReactNode; + /** + * The class name to apply to the tablist. + */ + className?: string; + /** + * Custom CSS styles for the rendered tablist. + */ + style?: React.CSSProperties; +}; + +export type TabProps = { + /** + * The id of the tab, which is prepended with the `Tabs` instanceId. + */ + id: string; + /** + * Custom CSS styles for the tab. + */ + style?: React.CSSProperties; + /** + * The children elements, generally the text to display on the tab. + */ + children?: React.ReactNode; + /** + * The class name to apply to the tab button. + */ + className?: string; + /** + * The icon used for the tab button. + */ + icon?: IconType; + /** + * Determines if the tab button should be disabled. + * + * @default false + */ + disabled?: boolean; + /** + * The type of component to render the tab button as. If this prop is not + * provided, the tab button will be rendered as a `button` element. + */ + render?: React.ReactElement; +}; + +export type TabPanelProps = { + /** + * The children elements, generally the content to display on the tabpanel. + */ + children?: React.ReactNode; + /** + * A unique identifier for the TabPanel, which is used to generate a unique `id` for the underlying element. + */ + id: string; + /** + * The class name to apply to the tabpanel. + */ + className?: string; + /** + * Custom CSS styles for the rendered `TabPanel` component. + */ + style?: React.CSSProperties; +};