From 978bec5ad02d31811f5f5111b02e01a6e73e6a99 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 4 Aug 2023 18:15:09 -0400
Subject: [PATCH 01/42] conjure components
---
packages/components/src/tabs/index.tsx | 265 ++++++++++++++++++
.../components/src/tabs/stories/index.tsx | 140 +++++++++
packages/components/src/tabs/types.ts | 193 +++++++++++++
3 files changed, 598 insertions(+)
create mode 100644 packages/components/src/tabs/index.tsx
create mode 100644 packages/components/src/tabs/stories/index.tsx
create mode 100644 packages/components/src/tabs/types.ts
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
new file mode 100644
index 0000000000000..b1f693af6eea6
--- /dev/null
+++ b/packages/components/src/tabs/index.tsx
@@ -0,0 +1,265 @@
+/**
+ * External dependencies
+ */
+import * as Ariakit from '@ariakit/react';
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import { useInstanceId } from '@wordpress/compose';
+import {
+ createContext,
+ useContext,
+ useEffect,
+ useLayoutEffect,
+} from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import type {
+ TabListProps,
+ TabPanelProps,
+ TabProps,
+ TabsContextProps,
+ TabsProps,
+} from './types';
+import Button from '../button';
+import warning from '@wordpress/warning';
+
+const TabsContext = createContext< TabsContextProps >( undefined );
+
+/**
+ * Tabs is a collection of React components that combine to render an
+ * ARIA-compliant TabPanel.
+ *
+ * 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.
+ *
+ * ```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
+ *
+ *
+ * );
+ * ```
+ *
+ */
+
+function Tabs( {
+ tabs,
+ activeClass = 'is-active',
+ selectOnMove = true,
+ initialTabId,
+ orientation = 'horizontal',
+ onSelect,
+ children,
+}: TabsProps ) {
+ const instanceId = useInstanceId( Tabs, 'tabs' );
+ const store = Ariakit.useTabStore( {
+ selectOnMove,
+ orientation,
+ defaultSelectedId: initialTabId && `${ instanceId }-${ initialTabId }`,
+ setSelectedId: onSelect,
+ } );
+
+ const { items, selectedId } = store.useState();
+ const selectedTab = items.find( ( item ) => item.id === selectedId );
+ const firstEnabledTab = items.find( ( item ) => {
+ // Ariakit internally refers to disabled tabs as `dimmed`.
+ return ! item.dimmed;
+ } );
+
+ // Handle selecting the initial tab.
+ useLayoutEffect( () => {
+ const initialTab = items.find(
+ ( item ) => item.id === `${ instanceId }-${ initialTabId }`
+ );
+
+ // 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 ( ! items.find( ( item ) => item.id === selectedId ) ) {
+ if ( initialTab && ! initialTab.dimmed ) {
+ store.setSelectedId( initialTab?.id );
+ } else {
+ store.setSelectedId( firstEnabledTab?.id );
+ }
+ }
+ }, [
+ firstEnabledTab?.id,
+ initialTabId,
+ instanceId,
+ store,
+ items,
+ selectedId,
+ ] );
+
+ // Handle the currently selected tab becoming disabled.
+ useEffect( () => {
+ if ( ! selectedTab?.dimmed ) {
+ return;
+ }
+ // If the currently selected tab becomes disabled, select the first enabled tab.
+ // (if there is one).
+ if ( firstEnabledTab ) {
+ store.setSelectedId( firstEnabledTab?.id );
+ }
+ }, [
+ items,
+ store,
+ selectedTab?.id,
+ selectedTab?.dimmed,
+ firstEnabledTab,
+ ] );
+
+ return (
+
+ { tabs ? (
+ <>
+
+ { tabs.map( ( tab ) => (
+
+ { ! tab.tab?.icon && tab.title }
+
+ ) ) }
+
+ { tabs.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+ >
+ ) : (
+ <>{ children }>
+ ) }
+
+ );
+}
+
+function TabList( { children, className, style }: TabListProps ) {
+ const context = useContext( TabsContext );
+ if ( ! context ) {
+ warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
+ return null;
+ }
+ const { store } = context;
+ return (
+
+ { children }
+
+ );
+}
+
+function Tab( {
+ children,
+ id,
+ className,
+ disabled,
+ icon,
+ title,
+ style,
+}: TabProps ) {
+ const context = useContext( TabsContext );
+ if ( ! context ) {
+ warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
+ return null;
+ }
+ const { store, instanceId, activeClass } = context;
+ const instancedTabId = `${ instanceId }-${ id }`;
+ return (
+
+ }
+ >
+ { children }
+
+ );
+}
+
+function TabPanel( { children, id, className, style }: TabPanelProps ) {
+ const context = useContext( TabsContext );
+ if ( ! context ) {
+ warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' );
+ return null;
+ }
+ const { store, instanceId } = context;
+
+ return (
+
+ { children }
+
+ );
+}
+
+Tabs.TabList = TabList;
+Tabs.Tab = Tab;
+Tabs.TabPanel = TabPanel;
+export default Tabs;
diff --git a/packages/components/src/tabs/stories/index.tsx b/packages/components/src/tabs/stories/index.tsx
new file mode 100644
index 0000000000000..e8ff2ba6bb155
--- /dev/null
+++ b/packages/components/src/tabs/stories/index.tsx
@@ -0,0 +1,140 @@
+/**
+ * External dependencies
+ */
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+
+/**
+ * WordPress dependencies
+ */
+// import { wordpress, more, link } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import Tabs from '..';
+// import Popover from '../../popover';
+// import { Provider as SlotFillProvider } from '../../slot-fill';
+
+const meta: ComponentMeta< typeof Tabs > = {
+ title: 'Components/Tabs',
+ component: Tabs,
+ parameters: {
+ actions: { argTypesRegex: '^on.*' },
+ controls: { expanded: true },
+ docs: { source: { state: 'open' } },
+ },
+};
+export default meta;
+
+const Template: ComponentStory< typeof Tabs > = ( props ) => {
+ const tabStore = Tabs.useTabStore( { tabs: props.tabs! } ); //TODO: remove bang
+ return (
+
+
+ { props.tabs?.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { props.tabs?.map( ( tab ) => (
+
+ Selected tab: { tab.title }
+
+ ) ) }
+
+ );
+};
+
+export const Default = Template.bind( {} );
+Default.args = {
+ // children: ( tab: any ) =>
Selected tab: { tab.title }
,
+ tabs: [
+ {
+ name: 'tab1',
+ title: 'Tab 1',
+ },
+ {
+ name: 'tab2',
+ title: 'Tab 2',
+ },
+ ],
+};
+
+// export const DisabledTab = Template.bind( {} );
+// DisabledTab.args = {
+// children: ( tab ) => Selected tab: { tab.title }
,
+// tabs: [
+// {
+// name: 'tab1',
+// title: 'Tab 1',
+// disabled: true,
+// },
+// {
+// name: 'tab2',
+// title: 'Tab 2',
+// },
+// {
+// name: 'tab3',
+// title: 'Tab 3',
+// },
+// ],
+// };
+
+// // SlotFillTemplate is used to ensure the icon's tooltips are not rendered
+// // inline, as that would cause them to inherit the tab's opacity.
+// const SlotFillTemplate: ComponentStory< typeof TabPanelV2 > = ( props ) => {
+// return (
+//
+//
+// { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ }
+//
+//
+// );
+// };
+
+// export const WithTabIconsAndTooltips = SlotFillTemplate.bind( {} );
+// WithTabIconsAndTooltips.args = {
+// children: ( tab ) => Selected tab: { tab.title }
,
+// tabs: [
+// {
+// name: 'tab1',
+// title: 'Tab 1',
+// icon: wordpress,
+// },
+// {
+// name: 'tab2',
+// title: 'Tab 2',
+// icon: link,
+// },
+// {
+// name: 'tab3',
+// title: 'Tab 3',
+// icon: more,
+// },
+// ],
+// };
+
+// export const ManualActivation = Template.bind( {} );
+// ManualActivation.args = {
+// children: ( tab ) => Selected tab: { tab.title }
,
+// tabs: [
+// {
+// name: 'tab1',
+// title: 'Tab 1',
+// },
+// {
+// name: 'tab2',
+// title: 'Tab 2',
+// },
+// ],
+// selectOnMove: false,
+// };
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
new file mode 100644
index 0000000000000..981ed60852cc5
--- /dev/null
+++ b/packages/components/src/tabs/types.ts
@@ -0,0 +1,193 @@
+/**
+ * External dependencies
+ */
+import type * as Ariakit from '@ariakit/react';
+
+/**
+ * Internal dependencies
+ */
+import type { IconType } from '../icon';
+
+type Tab = {
+ /**
+ * The key of the tab. Also used in the id of the tab button and the
+ * corresponding tabpanel.
+ */
+ id: string;
+ /**
+ * The label of the tab.
+ */
+ title: string;
+ /**
+ * The content to be displayed in the tabpanel when this tab is selected.
+ */
+ content: React.ReactNode;
+ /**
+ * Optional props to be applied to the tab button.
+ */
+ tab?: {
+ /**
+ * 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.
+ */
+ disabled?: boolean;
+ };
+ // TODO: evaluate if this is needed
+ /**
+ * Optional props to be applied to the tabpanel.
+ */
+} & Record< any, any >;
+
+export type TabsContextProps =
+ | {
+ /**
+ * The tabStore object returned by Ariakit's `useTabStore` hook.
+ */
+ store: Ariakit.TabStore;
+ /**
+ * The class name to add to the active tab.
+ */
+ activeClass: string;
+ /**
+ * The unique id string for this instance of the Tabs component.
+ */
+ instanceId: string;
+ }
+ | undefined;
+
+export type TabsProps =
+ // Because providing a tabs array will automatically render all of the
+ // subcomponents, we need to make sure that the children prop is not also
+ // provided.
+ (
+ | {
+ /**
+ * Array of tab objects. Each tab object should contain at least
+ * a `id`, a `title`, and a `content` value.
+ */
+ tabs: Tab[];
+ children?: never;
+ }
+ | {
+ tabs?: never;
+ /**
+ * The children elements, which should be at least a
+ * `Tabs.Tablist` component and a series of `Tabs.TabPanel`
+ * components.
+ */
+ children: React.ReactNode;
+ }
+ ) & {
+ /**
+ * The class name to add to the active tab.
+ *
+ * @default 'is-active'
+ */
+ activeClass?: string;
+ /**
+ * 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`.
+ */
+ initialTabId?: string;
+ /**
+ * The function called when a tab has been selected.
+ * It is passed the `instanceId`-prefixed `tabId` as an argument.
+ */
+ onSelect?:
+ | ( ( selectedId: string | null | undefined ) => void )
+ | undefined;
+ /**
+ * The orientation of the tablist.
+ *
+ * @default `horizontal`
+ */
+ orientation?: 'horizontal' | 'vertical';
+ };
+
+export type TabListProps = {
+ /**
+ * The children elements
+ */
+ 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: Tab[ 'id' ];
+ /**
+ * The label for the tab.
+ */
+ title: Tab[ 'title' ];
+ /**
+ * 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;
+};
+
+export type TabPanelProps = {
+ /**
+ * The children elements
+ */
+ children?: React.ReactNode;
+ /**
+ * The id of the TabPanel, which is combined with the `Tabs` instanceId and
+ * the suffix '-view'.
+ */
+ id: string;
+ /**
+ * The class name to apply to the tabpanel.
+ */
+ className?: string;
+ /**
+ * Custom CSS styles for the rendered `TabPanel` component.
+ */
+ style?: React.CSSProperties;
+};
From 7ffc88a3bce15814ae4b09d364f57b8b30f36994 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 4 Aug 2023 18:15:41 -0400
Subject: [PATCH 02/42] summon stories
---
.../src/tabs/stories/index.story.tsx | 295 ++++++++++++++++++
.../components/src/tabs/stories/index.tsx | 140 ---------
2 files changed, 295 insertions(+), 140 deletions(-)
create mode 100644 packages/components/src/tabs/stories/index.story.tsx
delete mode 100644 packages/components/src/tabs/stories/index.tsx
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..85d21271b9f2a
--- /dev/null
+++ b/packages/components/src/tabs/stories/index.story.tsx
@@ -0,0 +1,295 @@
+/**
+ * External dependencies
+ */
+import type { Meta, StoryFn } from '@storybook/react';
+
+/**
+ * WordPress dependencies
+ */
+import { wordpress, more, link } from '@wordpress/icons';
+
+/**
+ * Internal dependencies
+ */
+import Tabs from '..';
+import Popover from '../../popover';
+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/Tabs',
+ component: Tabs,
+ parameters: {
+ actions: { argTypesRegex: '^on.*' },
+ controls: { expanded: true },
+ docs: { canvas: { sourceState: 'shown' } },
+ },
+};
+export default meta;
+
+const Template: StoryFn< typeof Tabs > = ( props ) => {
+ return (
+ // Ignore reason: `children` and `tabs` props are mutually exclusive.
+ // When we ambiguously pass `props` here in Storybook, TS doesn't know
+ // what to expect, so it errors.
+ // @ts-expect-error
+
+
+
+ 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 (
+ // Ignore reason: `children` and `tabs` props are mutually exclusive.
+ // When we ambiguously pass `props` here in Storybook, TS doesn't know
+ // what to expect, so it errors.
+ // @ts-expect-error
+
+
+
+ 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 (
+ // SlotFill is used here to ensure the icon's tooltips are not
+ // rendered inline, as that would cause them to inherit the tab's opacity.
+
+ { /* Ignore reason: `children` and `tabs` props are mutually
+ exclusive. When we ambiguously pass `props` here in Storybook, TS
+ doesn't know what to expect, so it errors.
+ @ts-expect-error */ }
+
+
+
+
+
+
+
+ Selected tab: Tab 1
+
+
+ Selected tab: Tab 2
+
+
+ Selected tab: Tab 3
+
+
+ { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ }
+
+
+ );
+};
+export const WithTabIconsAndTooltips = WithTabIconsAndTooltipsTemplate.bind(
+ {}
+);
+
+export const ManualActivation = Template.bind( {} );
+ManualActivation.args = {
+ selectOnMove: false,
+};
+
+const UsingSlotFillTemplate: StoryFn< typeof Tabs > = ( props ) => {
+ return (
+
+ { /* Ignore reason: `children` and `tabs` props are mutually
+ exclusive. When we ambiguously pass `props` here in Storybook, TS
+ doesn't know what to expect, so it errors.
+ @ts-expect-error */ }
+
+
+
+ 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 ? (
+
+ { /* Ignore reason: `children` and `tabs` props are mutually
+ exclusive. When we ambiguously pass `props` here in Storybook, TS
+ doesn't know what to expect, so it errors.
+ @ts-expect-error */ }
+
+
+
+
+ Tab 1
+
+
+ Tab 2
+
+
+ Tab 3
+
+
+ setIsOpen( false ) }
+ >
+ Close Tabs
+
+
+
+ Selected tab: Tab 1
+
+
+ Selected tab: Tab 2
+
+
+ Selected tab: Tab 3
+
+
+
+ ) : (
+ setIsOpen( true ) }
+ >
+ Open Tabs
+
+ ) }
+ >
+ );
+};
+export const InsertCustomElements = CloseButtonTemplate.bind( {} );
+
+const MonolithicTemplate: StoryFn< typeof Tabs > = ( props ) => {
+ return ;
+};
+
+export const Monolithic = MonolithicTemplate.bind( {} );
+Monolithic.args = {
+ tabs: [
+ {
+ id: 'tab1',
+ title: 'Tab 1',
+ content: Selected tab: Tab 1
,
+ },
+ {
+ id: 'tab2',
+ title: 'Tab 2',
+ content: Selected tab: Tab 2
,
+ },
+ {
+ id: 'tab3',
+ title: 'Tab 3',
+ content: Selected tab: Tab 3
,
+ tab: { disabled: true },
+ },
+ {
+ id: 'tab4',
+ title: 'Tab 4',
+ content: Selected tab: Tab 4
,
+ tab: { icon: wordpress },
+ },
+ ],
+};
diff --git a/packages/components/src/tabs/stories/index.tsx b/packages/components/src/tabs/stories/index.tsx
deleted file mode 100644
index e8ff2ba6bb155..0000000000000
--- a/packages/components/src/tabs/stories/index.tsx
+++ /dev/null
@@ -1,140 +0,0 @@
-/**
- * External dependencies
- */
-import type { ComponentMeta, ComponentStory } from '@storybook/react';
-
-/**
- * WordPress dependencies
- */
-// import { wordpress, more, link } from '@wordpress/icons';
-
-/**
- * Internal dependencies
- */
-import Tabs from '..';
-// import Popover from '../../popover';
-// import { Provider as SlotFillProvider } from '../../slot-fill';
-
-const meta: ComponentMeta< typeof Tabs > = {
- title: 'Components/Tabs',
- component: Tabs,
- parameters: {
- actions: { argTypesRegex: '^on.*' },
- controls: { expanded: true },
- docs: { source: { state: 'open' } },
- },
-};
-export default meta;
-
-const Template: ComponentStory< typeof Tabs > = ( props ) => {
- const tabStore = Tabs.useTabStore( { tabs: props.tabs! } ); //TODO: remove bang
- return (
-
-
- { props.tabs?.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { props.tabs?.map( ( tab ) => (
-
- Selected tab: { tab.title }
-
- ) ) }
-
- );
-};
-
-export const Default = Template.bind( {} );
-Default.args = {
- // children: ( tab: any ) => Selected tab: { tab.title }
,
- tabs: [
- {
- name: 'tab1',
- title: 'Tab 1',
- },
- {
- name: 'tab2',
- title: 'Tab 2',
- },
- ],
-};
-
-// export const DisabledTab = Template.bind( {} );
-// DisabledTab.args = {
-// children: ( tab ) => Selected tab: { tab.title }
,
-// tabs: [
-// {
-// name: 'tab1',
-// title: 'Tab 1',
-// disabled: true,
-// },
-// {
-// name: 'tab2',
-// title: 'Tab 2',
-// },
-// {
-// name: 'tab3',
-// title: 'Tab 3',
-// },
-// ],
-// };
-
-// // SlotFillTemplate is used to ensure the icon's tooltips are not rendered
-// // inline, as that would cause them to inherit the tab's opacity.
-// const SlotFillTemplate: ComponentStory< typeof TabPanelV2 > = ( props ) => {
-// return (
-//
-//
-// { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ }
-//
-//
-// );
-// };
-
-// export const WithTabIconsAndTooltips = SlotFillTemplate.bind( {} );
-// WithTabIconsAndTooltips.args = {
-// children: ( tab ) => Selected tab: { tab.title }
,
-// tabs: [
-// {
-// name: 'tab1',
-// title: 'Tab 1',
-// icon: wordpress,
-// },
-// {
-// name: 'tab2',
-// title: 'Tab 2',
-// icon: link,
-// },
-// {
-// name: 'tab3',
-// title: 'Tab 3',
-// icon: more,
-// },
-// ],
-// };
-
-// export const ManualActivation = Template.bind( {} );
-// ManualActivation.args = {
-// children: ( tab ) => Selected tab: { tab.title }
,
-// tabs: [
-// {
-// name: 'tab1',
-// title: 'Tab 1',
-// },
-// {
-// name: 'tab2',
-// title: 'Tab 2',
-// },
-// ],
-// selectOnMove: false,
-// };
From 04e0269eb3314c1721b34356ec0e9ca98f716c1e Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Thu, 24 Aug 2023 07:41:09 -0400
Subject: [PATCH 03/42] unleash unit tests
---
packages/components/src/tabs/test/index.tsx | 1078 +++++++++++++++++++
1 file changed, 1078 insertions(+)
create mode 100644 packages/components/src/tabs/test/index.tsx
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
new file mode 100644
index 0000000000000..7c005f6565d74
--- /dev/null
+++ b/packages/components/src/tabs/test/index.tsx
@@ -0,0 +1,1078 @@
+/**
+ * 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';
+
+/**
+ * Internal dependencies
+ */
+import Tabs from '..';
+import Popover from '../../popover';
+import { Provider as SlotFillProvider } from '../../slot-fill';
+import type { TabsProps } from '../types';
+
+const UncontrolledTabs = ( props?: Omit< TabsProps, 'children' | 'tabs' > ) => {
+ return (
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+};
+
+const TABS = [
+ {
+ 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 = [
+ ...TABS,
+ {
+ id: 'delta',
+ title: 'Delta',
+ content: 'Selected tab: Delta',
+ tab: { className: 'delta-class', icon: media },
+ },
+];
+
+const getSelectedTab = async () =>
+ await screen.findByRole( 'tab', { selected: true } );
+
+let originalGetClientRects: () => DOMRectList;
+
+describe.each( [
+ [ 'uncontrolled', UncontrolledTabs ],
+ // The controlled component tests will be added once we certify the
+ // uncontrolled component's behaviour on trunk.
+ // [ 'controlled', Tabs ],
+] )( 'Tabs %s', ( ...modeAndComponent ) => {
+ const [ , Component ] = modeAndComponent;
+
+ 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' )
+ );
+ } );
+
+ it( 'should display a tooltip when hovering tabs provided with an icon', async () => {
+ const user = userEvent.setup();
+
+ render(
+ // In order for the tooltip to display properly, there needs to be
+ // `Popover.Slot` in which the `Popover` renders outside of the
+ // `Tabs` component, otherwise the tooltip renders inline.
+
+
+
+ { TABS.map( ( tab ) => (
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ }
+
+
+ );
+ const allTabs = screen.getAllByRole( 'tab' );
+
+ for ( let i = 0; i < allTabs.length; i++ ) {
+ expect(
+ screen.queryByText( TABS[ i ].title )
+ ).not.toBeInTheDocument();
+
+ await user.hover( allTabs[ i ] );
+
+ await waitFor( () =>
+ expect( screen.getByText( TABS[ i ].title ) ).toBeVisible()
+ );
+
+ await user.unhover( allTabs[ i ] );
+ }
+ } );
+
+ it( 'should display a tooltip when moving the selection via the keyboard on tabs provided with an icon', async () => {
+ const user = userEvent.setup();
+
+ const mockOnSelect = jest.fn();
+
+ render(
+ // In order for the tooltip to display properly, there needs to be
+ // `Popover.Slot` in which the `Popover` renders outside of the
+ // `Tabs` component, otherwise the tooltip renders inline.
+
+
+
+ { TABS.map( ( tab ) => (
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ }
+
+
+ );
+
+ expect( await getSelectedTab() ).not.toHaveTextContent( 'Alpha' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );
+ expect( await getSelectedTab() ).not.toHaveFocus();
+
+ // Tab to focus the tablist. Make sure alpha is focused, and that the
+ // corresponding tooltip is shown.
+ expect( screen.queryByText( 'Alpha' ) ).not.toBeInTheDocument();
+ await user.keyboard( '[Tab]' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ expect( screen.getByText( 'Alpha' ) ).toBeInTheDocument();
+ expect( await getSelectedTab() ).toHaveFocus();
+
+ // Move selection with arrow keys. Make sure beta is focused, and that
+ // the corresponding tooltip is shown.
+ expect( screen.queryByText( 'Beta' ) ).not.toBeInTheDocument();
+ await user.keyboard( '[ArrowRight]' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
+ expect( screen.getByText( 'Beta' ) ).toBeInTheDocument();
+ expect( await getSelectedTab() ).toHaveFocus();
+
+ // Move selection with arrow keys. Make sure gamma is focused, and that
+ // the corresponding tooltip is shown.
+ expect( screen.queryByText( 'Gamma' ) ).not.toBeInTheDocument();
+ await user.keyboard( '[ArrowRight]' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 3 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' );
+ expect( screen.getByText( 'Gamma' ) ).toBeInTheDocument();
+ expect( await getSelectedTab() ).toHaveFocus();
+
+ // Move selection with arrow keys. Make sure beta is focused, and that
+ // the corresponding tooltip is shown.
+ expect( screen.queryByText( 'Beta' ) ).not.toBeInTheDocument();
+ await user.keyboard( '[ArrowLeft]' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 4 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
+ expect( screen.getByText( 'Beta' ) ).toBeInTheDocument();
+ expect( await getSelectedTab() ).toHaveFocus();
+ } );
+ } );
+
+ describe( 'Without `initialTabId`', () => {
+ 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 mockOnSelect = jest.fn();
+ const { rerender } = render(
+
+ );
+
+ rerender(
+
+
+ { /* Remove alpha */ }
+ { TABS.slice( 1 ).map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ } );
+ } );
+
+ 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(
+
+
+ { /* Remove alpha */ }
+ { TABS.slice( 1 ).map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ 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 mockOnSelect = jest.fn();
+
+ const { rerender } = render(
+
+ );
+
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+
+ rerender(
+
+
+ { /* Remove gamma */ }
+ { TABS.slice( 0, 2 ).map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 );
+ expect(
+ screen.queryByRole( 'tab', { selected: true } )
+ ).not.toBeInTheDocument();
+ } );
+
+ it( 'waits for the tab with the `initialTabId` to be present in the `tabs` array before selecting it', async () => {
+ const mockOnSelect = jest.fn();
+ const { rerender } = render(
+
+ );
+
+ // There should be no selected tab yet.
+ expect(
+ screen.queryByRole( 'tab', { selected: true } )
+ ).not.toBeInTheDocument();
+
+ rerender(
+
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ 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();
+
+ render(
+
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ expect(
+ screen.getByRole( 'tab', { name: 'Delta' } )
+ ).toHaveAttribute( 'aria-disabled', 'true' );
+
+ // onSelect gets called on the initial render.
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+
+ // onSelect should not be called since the disabled tab is
+ // highlighted, but not selected.
+ await user.keyboard( '[ArrowLeft]' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'should select first enabled tab when the initial tab is disabled', async () => {
+ const mockOnSelect = jest.fn();
+
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ // As alpha (first tab) is disabled,
+ // the first enabled tab should be beta.
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+
+ // Re-enable all tabs
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ // 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 mockOnSelect = jest.fn();
+
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ // 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(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ // 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' );
+
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
+
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ 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' );
+
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );
+
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ } );
+ } );
+
+ 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.
+ 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 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.
+ 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();
+
+ // 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();
+
+ render(
+
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ // 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();
+ 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 with the default
+ // selected tab.
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+
+ // 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.
+ 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.
+ 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( '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'
+ );
+ } );
+
+ it( 'should apply the `activeClass` to the selected tab', async () => {
+ const user = userEvent.setup();
+ const activeClass = 'my-active-tab';
+
+ render( );
+
+ // Make sure that only the selected tab has the active class
+ expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
+ expect( await getSelectedTab() ).toHaveClass( activeClass );
+ screen
+ .getAllByRole( 'tab', { selected: false } )
+ .forEach( ( unselectedTab ) => {
+ expect( unselectedTab ).not.toHaveClass( activeClass );
+ } );
+
+ // Click the 'Beta' tab
+ await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) );
+
+ // Make sure that only the selected tab has the active class
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ expect( await getSelectedTab() ).toHaveClass( activeClass );
+ screen
+ .getAllByRole( 'tab', { selected: false } )
+ .forEach( ( unselectedTab ) => {
+ expect( unselectedTab ).not.toHaveClass( activeClass );
+ } );
+ } );
+ } );
+} );
From ca4781831b1368035ca460f395324fde8f51abff Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Tue, 29 Aug 2023 17:38:41 -0400
Subject: [PATCH 04/42] create controlled mode
---
packages/components/src/tabs/index.tsx | 47 +-
.../src/tabs/stories/index.story.tsx | 77 +
packages/components/src/tabs/test/index.tsx | 2158 ++++++++++++-----
packages/components/src/tabs/types.ts | 12 +
4 files changed, 1611 insertions(+), 683 deletions(-)
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index b1f693af6eea6..e472023902362 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -80,16 +80,28 @@ function Tabs( {
orientation = 'horizontal',
onSelect,
children,
+ selectedTabId,
}: TabsProps ) {
const instanceId = useInstanceId( Tabs, 'tabs' );
const store = Ariakit.useTabStore( {
selectOnMove,
orientation,
defaultSelectedId: initialTabId && `${ instanceId }-${ initialTabId }`,
- setSelectedId: onSelect,
+ 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;
+
const selectedTab = items.find( ( item ) => item.id === selectedId );
const firstEnabledTab = items.find( ( item ) => {
// Ariakit internally refers to disabled tabs as `dimmed`.
@@ -98,6 +110,10 @@ function Tabs( {
// Handle selecting the initial tab.
useLayoutEffect( () => {
+ if ( isControlled ) {
+ return;
+ }
+
const initialTab = items.find(
( item ) => item.id === `${ instanceId }-${ initialTabId }`
);
@@ -114,18 +130,19 @@ function Tabs( {
// fall back to the initial tab or the first enabled tab.
if ( ! items.find( ( item ) => item.id === selectedId ) ) {
if ( initialTab && ! initialTab.dimmed ) {
- store.setSelectedId( initialTab?.id );
+ setSelectedId( initialTab?.id );
} else {
- store.setSelectedId( firstEnabledTab?.id );
+ setSelectedId( firstEnabledTab?.id );
}
}
}, [
firstEnabledTab?.id,
initialTabId,
instanceId,
- store,
+ isControlled,
items,
selectedId,
+ setSelectedId,
] );
// Handle the currently selected tab becoming disabled.
@@ -133,18 +150,20 @@ function Tabs( {
if ( ! selectedTab?.dimmed ) {
return;
}
- // If the currently selected tab becomes disabled, select the first enabled tab.
- // (if there is one).
+
+ // 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, select the first
+ // enabled tab (if there is one).
if ( firstEnabledTab ) {
- store.setSelectedId( firstEnabledTab?.id );
+ setSelectedId( firstEnabledTab?.id );
}
- }, [
- items,
- store,
- selectedTab?.id,
- selectedTab?.dimmed,
- firstEnabledTab,
- ] );
+ }, [ firstEnabledTab, isControlled, selectedTab?.dimmed, setSelectedId ] );
return (
diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx
index 85d21271b9f2a..2d0f63e16d45b 100644
--- a/packages/components/src/tabs/stories/index.story.tsx
+++ b/packages/components/src/tabs/stories/index.story.tsx
@@ -7,6 +7,7 @@ import type { Meta, StoryFn } from '@storybook/react';
* WordPress dependencies
*/
import { wordpress, more, link } from '@wordpress/icons';
+import { useState } from '@wordpress/element';
/**
* Internal dependencies
@@ -293,3 +294,79 @@ Monolithic.args = {
},
],
};
+
+const ControlledModeTemplate: StoryFn< typeof Tabs > = ( props ) => {
+ const [ selectedTabId, setSelectedTabId ] = useState<
+ string | undefined | null
+ >( props.selectedTabId );
+
+ return (
+ <>
+ { /* Ignore reason: `children` and `tabs` props are mutually
+ exclusive. When we ambiguously pass `props` here in Storybook, TS
+ doesn't know what to expect, so it errors.
+ @ts-expect-error */ }
+ {
+ 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',
+};
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index 7c005f6565d74..a2b4a41564acd 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -8,6 +8,7 @@ import userEvent from '@testing-library/user-event';
* WordPress dependencies
*/
import { wordpress, category, media } from '@wordpress/icons';
+import { useState } from '@wordpress/element';
/**
* Internal dependencies
@@ -19,7 +20,8 @@ import type { TabsProps } from '../types';
const UncontrolledTabs = ( props?: Omit< TabsProps, 'children' | 'tabs' > ) => {
return (
-
+ // Force `selectedTabId` to `undefined` to maintain uncontrolled mode
+
{ TABS.map( ( tab ) => (
) => {
);
};
+const ControlledTabs = ( props: TabsProps ) => {
+ const [ selectedTabId, setSelectedTabId ] = useState<
+ string | undefined | null
+ >( props?.selectedTabId );
+
+ return (
+ // @ts-expect-error
+ {
+ setSelectedTabId( selectedId );
+ props?.onSelect?.( selectedId );
+ } }
+ >
+ { props.children }
+
+ );
+};
+
const TABS = [
{
id: 'alpha',
@@ -77,14 +99,7 @@ const getSelectedTab = async () =>
let originalGetClientRects: () => DOMRectList;
-describe.each( [
- [ 'uncontrolled', UncontrolledTabs ],
- // The controlled component tests will be added once we certify the
- // uncontrolled component's behaviour on trunk.
- // [ 'controlled', Tabs ],
-] )( 'Tabs %s', ( ...modeAndComponent ) => {
- const [ , Component ] = modeAndComponent;
-
+describe( 'Tabs', () => {
beforeAll( () => {
originalGetClientRects = window.HTMLElement.prototype.getClientRects;
// Mocking `getClientRects()` is necessary to pass a check performed by
@@ -102,7 +117,7 @@ describe.each( [
describe( 'Accessibility and semantics', () => {
it( 'should use the correct aria attributes', async () => {
- render( );
+ render( );
const tabList = screen.getByRole( 'tablist' );
const allTabs = screen.getAllByRole( 'tab' );
@@ -250,140 +265,250 @@ describe.each( [
expect( await getSelectedTab() ).toHaveFocus();
} );
} );
+ 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'
+ );
+ } );
+
+ it( 'should apply the `activeClass` to the selected tab', async () => {
+ const user = userEvent.setup();
+ const activeClass = 'my-active-tab';
+
+ render( );
+
+ // Make sure that only the selected tab has the active class
+ expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
+ expect( await getSelectedTab() ).toHaveClass( activeClass );
+ screen
+ .getAllByRole( 'tab', { selected: false } )
+ .forEach( ( unselectedTab ) => {
+ expect( unselectedTab ).not.toHaveClass( activeClass );
+ } );
+
+ // Click the 'Beta' tab
+ await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) );
+
+ // Make sure that only the selected tab has the active class
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ expect( await getSelectedTab() ).toHaveClass( activeClass );
+ screen
+ .getAllByRole( 'tab', { selected: false } )
+ .forEach( ( unselectedTab ) => {
+ expect( unselectedTab ).not.toHaveClass( activeClass );
+ } );
+ } );
+ } );
- describe( 'Without `initialTabId`', () => {
- it( 'should render first tab', async () => {
- render( );
+ 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' );
- it( 'should fall back to first enabled tab if the active tab is removed', async () => {
- const mockOnSelect = jest.fn();
- const { rerender } = render(
-
- );
+ // Click on Beta, make sure beta is the selected tab
+ await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) );
- rerender(
-
-
- { /* Remove alpha */ }
- { TABS.slice( 1 ).map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
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' );
} );
- } );
- describe( 'With `initialTabId`', () => {
- it( 'should render the tab set by initialTabId prop', async () => {
- render( );
+ 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.
+ 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 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( 'should not select a tab when `initialTabId` does not match any known tab', () => {
- render( );
+ it( 'wraps around the last/first tab when using arrow keys', async () => {
+ const user = userEvent.setup();
+ const mockOnSelect = jest.fn();
- // No tab should be selected i.e. it doesn't fall back to first tab.
- expect(
- screen.queryByRole( 'tab', { selected: true } )
- ).not.toBeInTheDocument();
+ render( );
- // 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( );
+ // onSelect gets called on the initial render.
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- rerender( );
+ // 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();
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ // 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 fall back to the tab associated to `initialTabId` if the currently active tab is removed', async () => {
+ 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(
-
+
);
- expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+ // onSelect gets called on the initial render.
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- await user.click( screen.getByRole( 'tab', { name: '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(
-
-
- { /* Remove alpha */ }
- { TABS.slice( 1 ).map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
- expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- } );
+ expect( screen.getByRole( 'tablist' ) ).toHaveAttribute(
+ 'aria-orientation',
+ 'vertical'
+ );
- it( 'should have no active tabs when the tab associated to `initialTabId` is removed while being the active tab', async () => {
- const mockOnSelect = jest.fn();
+ // Make sure alpha is still focused.
+ expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
+ expect( await getSelectedTab() ).toHaveFocus();
- const { rerender } = render(
-
- );
+ // 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' );
- rerender(
-
+ // 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();
+
+ render(
+
- { /* Remove gamma */ }
- { TABS.slice( 0, 2 ).map( ( tab ) => (
+ { TABS_WITH_DELTA.map( ( tab ) => (
{ tab.title }
) ) }
- { TABS.map( ( tab ) => (
+ { TABS_WITH_DELTA.map( ( tab ) => (
{ tab.content }
@@ -391,688 +516,1383 @@ describe.each( [
);
- expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 );
- expect(
- screen.queryByRole( 'tab', { selected: true } )
- ).not.toBeInTheDocument();
- } );
-
- it( 'waits for the tab with the `initialTabId` to be present in the `tabs` array before selecting it', async () => {
- const mockOnSelect = jest.fn();
- const { rerender } = 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();
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- // There should be no selected tab yet.
+ // 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.queryByRole( 'tab', { selected: true } )
- ).not.toBeInTheDocument();
+ screen.getByRole( 'tab', { name: 'Delta' } )
+ ).toHaveFocus();
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 3 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' );
- rerender(
-
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+ // 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 );
- expect( await getSelectedTab() ).toHaveTextContent( 'Delta' );
+ // 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 );
} );
- } );
- describe( 'Disabled Tab', () => {
- it( 'should disable the tab when `disabled` is `true`', async () => {
+ 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(
-
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
- expect(
- screen.getByRole( 'tab', { name: 'Delta' } )
- ).toHaveAttribute( 'aria-disabled', 'true' );
+ // onSelect gets called on the initial render with the default
+ // selected tab.
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- // onSelect gets called on the initial render.
+ // 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' );
- // onSelect should not be called since the disabled tab is
- // highlighted, but not selected.
- await user.keyboard( '[ArrowLeft]' );
+ // Navigate forward with arrow keys. Make sure Beta is focused, but
+ // that the tab selection happens only when pressing the spacebar
+ // or enter key.
+ await user.keyboard( '[ArrowRight]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- } );
+ expect(
+ await screen.findByRole( 'tab', { name: 'Beta' } )
+ ).toHaveFocus();
- it( 'should select first enabled tab when the initial tab is disabled', async () => {
- const mockOnSelect = jest.fn();
+ await user.keyboard( '[Enter]' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
- const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+ // 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.
+ await user.keyboard( '[ArrowRight]' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
+ expect(
+ screen.getByRole( 'tab', { name: 'Gamma' } )
+ ).toHaveFocus();
- // As alpha (first tab) is disabled,
- // the first enabled tab should be beta.
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ 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( );
- // Re-enable all tabs
- rerender(
-
-
+ 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(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
{ TABS.map( ( tab ) => (
-
- { tab.title }
-
+
+ { tab.content }
+
) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+
+ );
- // Even if the initial tab becomes enabled again, the selected tab doesn't
- // change.
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ rerender(
+
+
+ { /* Remove alpha */ }
+ { TABS.slice( 1 ).map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.slice( 1 ).map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ } );
} );
+ describe( 'With `initialTabId`', () => {
+ it( 'should render the tab set by `initialTabId` prop', async () => {
+ render( );
- it( 'should select first enabled tab when the tab associated to `initialTabId` is disabled', async () => {
- const mockOnSelect = jest.fn();
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ } );
- const { rerender } = render(
-
-
+ 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(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
{ TABS.map( ( tab ) => (
-
- { tab.title }
-
+
+ { tab.content }
+
) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+
+ );
- // As alpha (first tab), and beta (the initial tab), are both
- // disabled the first enabled tab should be gamma.
- expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- // Re-enable all tabs
- rerender(
-
-
+ await user.click(
+ screen.getByRole( 'tab', { name: 'Alpha' } )
+ );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );
+
+ rerender(
+
+
+ { /* Remove alpha */ }
+ { TABS.slice( 1 ).map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.slice( 1 ).map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+
+ 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(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
{ TABS.map( ( tab ) => (
-
- { tab.title }
-
+
+ { tab.content }
+
) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+
+ );
- // Even if the initial tab becomes enabled again, the selected tab doesn't
- // change.
- expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- } );
+ 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(
-
- );
+ rerender(
+
+
+ { /* Remove gamma */ }
+ { TABS.slice( 0, 2 ).map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.slice( 0, 2 ).map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );
+ 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();
+ } );
- rerender(
-
-
+ it( 'waits for the tab with the `initialTabId` to be present in the `tabs` array before selecting it', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
{ TABS.map( ( tab ) => (
-
- { tab.title }
-
+
+ { tab.content }
+
) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+
+ );
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
+ // There should be no selected tab yet.
+ expect(
+ screen.queryByRole( 'tab', { selected: true } )
+ ).not.toBeInTheDocument();
- rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
+ rerender(
+
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.content }
+
) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+
+ );
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
+ 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();
- 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();
+ render(
+
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- const { rerender } = render(
-
- );
+ expect(
+ screen.getByRole( 'tab', { name: 'Delta' } )
+ ).toHaveAttribute( 'aria-disabled', 'true' );
- expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+ // onSelect gets called on the initial render.
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- rerender(
-
-
+ // onSelect should not be called since the disabled tab is
+ // highlighted, but not selected.
+ await user.keyboard( '[ArrowLeft]' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ } );
+
+ it( 'should select first enabled tab when the initial tab is disabled', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
{ TABS.map( ( tab ) => (
-
- { tab.title }
-
+
+ { tab.content }
+
) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+
+ );
- expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );
+ // As alpha (first tab) is disabled,
+ // the first enabled tab should be beta.
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- rerender(
-
-
+ // Re-enable all tabs
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
{ TABS.map( ( tab ) => (
-
- { tab.title }
-
+
+ { tab.content }
+
) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
-
- expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- } );
- } );
-
- 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' );
+ // Even if the initial tab becomes enabled again, the selected
+ // tab doesn't change.
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ } );
- // Click on Beta, make sure beta is the selected tab
- await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) );
+ it( 'should select first enabled tab when the tab associated to `initialTabId` is disabled', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- expect(
- screen.getByRole( 'tabpanel', { name: 'Beta' } )
- ).toBeInTheDocument();
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
+ // As alpha (first tab), and beta (the initial tab), are both
+ // disabled the first enabled tab should be gamma.
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- // Click on Alpha, make sure beta is the selected tab
- await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) );
+ // Re-enable all tabs
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
- expect(
- screen.getByRole( 'tabpanel', { name: 'Alpha' } )
- ).toBeInTheDocument();
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );
- } );
+ // Even if the initial tab becomes enabled again, the selected tab doesn't
+ // change.
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+ } );
- it( 'defaults to automatic tab activation (arrow keys)', async () => {
- const user = userEvent.setup();
- const mockOnSelect = jest.fn();
+ it( 'should select the first enabled tab when the selected tab becomes disabled', async () => {
+ const mockOnSelect = jest.fn();
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- render( );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );
- // onSelect gets called on the initial render.
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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();
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
- // 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' );
+ // Re-enable all tabs
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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' );
- } );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
+ } );
- it( 'wraps around the last/first tab when using arrow keys', async () => {
- const user = userEvent.setup();
- const mockOnSelect = jest.fn();
+ 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(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- render( );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- // onSelect gets called on the initial render.
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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();
+ expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );
- // 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' );
+ // Re-enable all tabs
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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' );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
+ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ } );
} );
+ } );
- 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(
-
+ describe( 'Controlled mode', () => {
+ it( 'should not render any tab if `selectedTabId` does not match any known tab', () => {
+ render(
+
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
);
- // 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();
+ // 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 render any tab if `selectedTabId` refers to an disabled tab', async () => {
+ render(
+
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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();
+ // 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( 'Without `initialTabId` prop', () => {
+ it( 'should render the tab specified by the `specifiedTabId` prop', async () => {
+ render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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' );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ expect(
+ await screen.findByRole( 'tabpanel', { name: 'Beta' } )
+ ).toBeInTheDocument();
+ } );
+ it( 'should not render any tab if the active tab is removed', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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' );
+ rerender(
+
+
+ { /* Remove beta */ }
+ { TABS.filter( ( tab ) => tab.id !== 'beta' ).map(
+ ( tab ) => (
+
+ { tab.title }
+
+ )
+ ) }
+
+ { TABS.filter( ( tab ) => tab.id !== 'beta' ).map(
+ ( tab ) => (
+
+ { tab.content }
+
+ )
+ ) }
+
+ );
- // 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' );
+ expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 );
- // 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' );
+ // 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();
+ } );
} );
+ describe( 'With `initialTabId`', () => {
+ it( 'should render the specified `selectedTabId`, and ignore the `initialTabId` prop', async () => {
+ render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- 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();
-
- render(
-
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.title }
-
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+ } );
+ it( 'should render the specified `selectedTabId` when `initialTabId` does not match any known tab', async () => {
+ render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
) ) }
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+
+ );
- // onSelect gets called on the initial render.
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ // No tab should be selected i.e. it doesn't fall back to first tab.
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ } );
+ it( 'should not change tabs when initialTabId is changed', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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();
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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' );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+ } );
+ it( 'should not render any tab if the currently active tab is removed', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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 );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- // 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 );
- } );
+ rerender(
+
+
+ { /* Remove beta */ }
+ { TABS.filter( ( tab ) => tab.id !== 'beta' ).map(
+ ( tab ) => (
+
+ { tab.title }
+
+ )
+ ) }
+
+ { TABS.filter( ( tab ) => tab.id !== 'beta' ).map(
+ ( tab ) => (
+
+ { tab.content }
+
+ )
+ ) }
+
+ );
- it( 'should not focus the next tab when the Tab key is pressed', async () => {
- const user = userEvent.setup();
+ 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( 'should have no active tabs when the tab associated to `initialTabId` is removed while being the active tab', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- render( );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- // 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();
+ rerender(
+
+
+ { /* Remove gamma */ }
+ { TABS.slice( 0, 2 ).map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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();
- } );
+ 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( 'does not select `initialTabId` if it becomes available after the initial render', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => {
- const user = userEvent.setup();
- const mockOnSelect = jest.fn();
+ // The controlled tab, Beta, should be selected.
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- render(
-
- );
+ rerender(
+
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS_WITH_DELTA.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // onSelect gets called on the initial render with the default
- // selected tab.
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
+ // Beta should remain selected, even after the `initialTabId` of Delta becomes available.
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ } );
+ } );
+ describe( 'Disabled tab', () => {
+ it( 'should render the specified `selectedTabId` (not the first enabled tab) when the tab associated to `initialTabId` is disabled', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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' );
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- // Navigate forward with arrow keys. Make sure Beta is focused, but
- // that the tab selection happens only when pressing the spacebar
- // or enter key.
- await user.keyboard( '[ArrowRight]' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- expect(
- await screen.findByRole( 'tab', { name: 'Beta' } )
- ).toHaveFocus();
+ // Re-enable all tabs
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- await user.keyboard( '[Enter]' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
+ // Even if the initial tab becomes enabled again, the selected tab doesn't
+ // change.
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+ } );
+ it( 'should not render any tab when the selected tab becomes disabled', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // 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.
- await user.keyboard( '[ArrowRight]' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
- expect(
- screen.getByRole( 'tab', { name: 'Gamma' } )
- ).toHaveFocus();
+ expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- await user.keyboard( '[Space]' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 3 );
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' );
- } );
- } );
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
+ // 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();
- describe( 'Tab Attributes', () => {
- it( "should apply the tab's `className` to the tab button", async () => {
- render( );
+ // re-enable all tabs
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- 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'
- );
- } );
+ // 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();
+ } );
+ it( 'should not render any tab when the tab associated to `initialTabId` becomes disabled while being the active tab', async () => {
+ const { rerender } = render(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- it( 'should apply the `activeClass` to the selected tab', async () => {
- const user = userEvent.setup();
- const activeClass = 'my-active-tab';
+ expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- render( );
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // Make sure that only the selected tab has the active class
- expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
- expect( await getSelectedTab() ).toHaveClass( activeClass );
- screen
- .getAllByRole( 'tab', { selected: false } )
- .forEach( ( unselectedTab ) => {
- expect( unselectedTab ).not.toHaveClass( activeClass );
+ // 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();
- // Click the 'Beta' tab
- await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) );
+ // re-enable all tabs
+ rerender(
+
+
+ { TABS.map( ( tab ) => (
+
+ { tab.title }
+
+ ) ) }
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
+ );
- // Make sure that only the selected tab has the active class
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- expect( await getSelectedTab() ).toHaveClass( activeClass );
- screen
- .getAllByRole( 'tab', { selected: false } )
- .forEach( ( unselectedTab ) => {
- expect( unselectedTab ).not.toHaveClass( activeClass );
- } );
+ // 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();
+ } );
} );
} );
+
+ describe( 'Without `initialTabId` prop', () => {
+ describe( 'in uncontrolled mode', () => {} );
+ describe( 'in controlled mode', () => {} );
+ } );
+ describe( 'With `initialTabId`', () => {
+ describe( 'in uncontrolled mode', () => {} );
+ describe( 'in controlled mode', () => {} );
+ } );
+ describe( 'Controlled Mode', () => {} );
+ describe( 'Disabled Tab', () => {
+ describe( 'in uncontrolled mode', () => {} );
+ describe( 'in controlled mode', () => {} );
+ } );
} );
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
index 981ed60852cc5..0d51fa0e6df3d 100644
--- a/packages/components/src/tabs/types.ts
+++ b/packages/components/src/tabs/types.ts
@@ -107,6 +107,9 @@ export type TabsProps =
* 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;
/**
@@ -122,6 +125,15 @@ export type TabsProps =
* @default `horizontal`
*/
orientation?: 'horizontal' | 'vertical';
+ /**
+ * The Id of the tab to display. This id is prepended with the `Tabs`
+ * instanceId internally.
+ *
+ * This prop puts the component into controlled mode. A value of
+ * `undefined` returns the component to uncontrolled mode. A value of
+ * `null` will result in no tab being selected.
+ */
+ selectedTabId?: string | null;
};
export type TabListProps = {
From 00db04b9e9f6a2d9f9496e68864a9aafc9d8ae8b Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Tue, 12 Sep 2023 10:08:40 -0400
Subject: [PATCH 05/42] remove monolithic flow and tabs array
---
packages/components/src/tabs/index.tsx | 25 +--
.../src/tabs/stories/index.story.tsx | 56 ------
packages/components/src/tabs/test/index.tsx | 1 -
packages/components/src/tabs/types.ts | 171 ++++++------------
4 files changed, 60 insertions(+), 193 deletions(-)
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index e472023902362..04a8193dbde48 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -73,7 +73,6 @@ const TabsContext = createContext< TabsContextProps >( undefined );
*/
function Tabs( {
- tabs,
activeClass = 'is-active',
selectOnMove = true,
initialTabId,
@@ -167,29 +166,7 @@ function Tabs( {
return (
- { tabs ? (
- <>
-
- { tabs.map( ( tab ) => (
-
- { ! tab.tab?.icon && tab.title }
-
- ) ) }
-
- { tabs.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
- >
- ) : (
- <>{ children }>
- ) }
+ { children }
);
}
diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx
index 2d0f63e16d45b..481079c45f0e4 100644
--- a/packages/components/src/tabs/stories/index.story.tsx
+++ b/packages/components/src/tabs/stories/index.story.tsx
@@ -31,10 +31,6 @@ export default meta;
const Template: StoryFn< typeof Tabs > = ( props ) => {
return (
- // Ignore reason: `children` and `tabs` props are mutually exclusive.
- // When we ambiguously pass `props` here in Storybook, TS doesn't know
- // what to expect, so it errors.
- // @ts-expect-error
@@ -64,10 +60,6 @@ export const Default = Template.bind( {} );
const DisabledTabTemplate: StoryFn< typeof Tabs > = ( props ) => {
return (
- // Ignore reason: `children` and `tabs` props are mutually exclusive.
- // When we ambiguously pass `props` here in Storybook, TS doesn't know
- // what to expect, so it errors.
- // @ts-expect-error
@@ -99,10 +91,6 @@ const WithTabIconsAndTooltipsTemplate: StoryFn< typeof Tabs > = ( props ) => {
// SlotFill is used here to ensure the icon's tooltips are not
// rendered inline, as that would cause them to inherit the tab's opacity.
- { /* Ignore reason: `children` and `tabs` props are mutually
- exclusive. When we ambiguously pass `props` here in Storybook, TS
- doesn't know what to expect, so it errors.
- @ts-expect-error */ }
= ( props ) => {
return (
- { /* Ignore reason: `children` and `tabs` props are mutually
- exclusive. When we ambiguously pass `props` here in Storybook, TS
- doesn't know what to expect, so it errors.
- @ts-expect-error */ }
@@ -206,10 +190,6 @@ const CloseButtonTemplate: StoryFn< typeof Tabs > = ( props ) => {
borderRight: '1px solid #333',
} }
>
- { /* Ignore reason: `children` and `tabs` props are mutually
- exclusive. When we ambiguously pass `props` here in Storybook, TS
- doesn't know what to expect, so it errors.
- @ts-expect-error */ }
= ( props ) => {
};
export const InsertCustomElements = CloseButtonTemplate.bind( {} );
-const MonolithicTemplate: StoryFn< typeof Tabs > = ( props ) => {
- return
;
-};
-
-export const Monolithic = MonolithicTemplate.bind( {} );
-Monolithic.args = {
- tabs: [
- {
- id: 'tab1',
- title: 'Tab 1',
- content:
Selected tab: Tab 1
,
- },
- {
- id: 'tab2',
- title: 'Tab 2',
- content:
Selected tab: Tab 2
,
- },
- {
- id: 'tab3',
- title: 'Tab 3',
- content:
Selected tab: Tab 3
,
- tab: { disabled: true },
- },
- {
- id: 'tab4',
- title: 'Tab 4',
- content:
Selected tab: Tab 4
,
- tab: { icon: wordpress },
- },
- ],
-};
-
const ControlledModeTemplate: StoryFn< typeof Tabs > = ( props ) => {
const [ selectedTabId, setSelectedTabId ] = useState<
string | undefined | null
@@ -302,10 +250,6 @@ const ControlledModeTemplate: StoryFn< typeof Tabs > = ( props ) => {
return (
<>
- { /* Ignore reason: `children` and `tabs` props are mutually
- exclusive. When we ambiguously pass `props` here in Storybook, TS
- doesn't know what to expect, so it errors.
- @ts-expect-error */ }
{
>( props?.selectedTabId );
return (
- // @ts-expect-error
;
-
export type TabsContextProps =
| {
/**
@@ -62,79 +25,63 @@ export type TabsContextProps =
}
| undefined;
-export type TabsProps =
- // Because providing a tabs array will automatically render all of the
- // subcomponents, we need to make sure that the children prop is not also
- // provided.
- (
- | {
- /**
- * Array of tab objects. Each tab object should contain at least
- * a `id`, a `title`, and a `content` value.
- */
- tabs: Tab[];
- children?: never;
- }
- | {
- tabs?: never;
- /**
- * The children elements, which should be at least a
- * `Tabs.Tablist` component and a series of `Tabs.TabPanel`
- * components.
- */
- children: React.ReactNode;
- }
- ) & {
- /**
- * The class name to add to the active tab.
- *
- * @default 'is-active'
- */
- activeClass?: string;
- /**
- * 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 `instanceId`-prefixed `tabId` as an argument.
- */
- onSelect?:
- | ( ( selectedId: string | null | undefined ) => void )
- | undefined;
- /**
- * 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.
- *
- * This prop puts the component into controlled mode. A value of
- * `undefined` returns the component to uncontrolled mode. A value of
- * `null` will result in no tab being selected.
- */
- selectedTabId?: string | null;
- };
+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;
+ /**
+ * The class name to add to the active tab.
+ *
+ * @default 'is-active'
+ */
+ activeClass?: string;
+ /**
+ * 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 `instanceId`-prefixed `tabId` as an argument.
+ */
+ onSelect?:
+ | ( ( selectedId: string | null | undefined ) => void )
+ | undefined;
+ /**
+ * 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.
+ *
+ * This prop puts the component into controlled mode. A value of
+ * `undefined` returns the component to uncontrolled mode. A value of
+ * `null` will result in no tab being selected.
+ */
+ selectedTabId?: string | null;
+};
export type TabListProps = {
/**
@@ -155,11 +102,11 @@ export type TabProps = {
/**
* The id of the tab, which is prepended with the `Tabs` instanceId.
*/
- id: Tab[ 'id' ];
+ id: string;
/**
* The label for the tab.
*/
- title: Tab[ 'title' ];
+ title: string;
/**
* Custom CSS styles for the tab.
*/
From 298defe614256a01cbbbd3db19a6cab3ab060644 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Tue, 12 Sep 2023 11:24:10 -0400
Subject: [PATCH 06/42] specify styles
---
packages/components/src/style.scss | 1 +
packages/components/src/tabs/index.tsx | 11 +---
packages/components/src/tabs/style.scss | 83 +++++++++++++++++++++++++
3 files changed, 87 insertions(+), 8 deletions(-)
create mode 100644 packages/components/src/tabs/style.scss
diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss
index 02dce83f66dcc..b28eac22d1a7c 100644
--- a/packages/components/src/style.scss
+++ b/packages/components/src/style.scss
@@ -48,6 +48,7 @@
@import "./select-control/style.scss";
@import "./snackbar/style.scss";
@import "./tab-panel/style.scss";
+@import "./tabs/style.scss";
@import "./text-control/style.scss";
@import "./tip/style.scss";
@import "./toolbar/toolbar/style.scss";
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index 04a8193dbde48..cd224055715f3 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -209,14 +209,9 @@ function Tab( {
Date: Tue, 12 Sep 2023 14:04:05 -0400
Subject: [PATCH 07/42] render README
---
packages/components/src/tabs/README.md | 290 +++++++++++++++++++++++++
1 file changed, 290 insertions(+)
create mode 100644 packages/components/src/tabs/README.md
diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md
new file mode 100644
index 0000000000000..e9f447e86e8ef
--- /dev/null
+++ b/packages/components/src/tabs/README.md
@@ -0,0 +1,290 @@
+# TabPanel
+
+Tabs is a collection of React components that combine to render an ARIA-compliant TabPanel.
+
+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.
+
+![The “Document” tab selected in the sidebar TabPanel.](https://wordpress.org/gutenberg/files/2019/01/s_E36D9C9B8FFA15A1A8CE224E422535A12B016F88884089575F9998E52016A49F_1541785098230_TabPanel.png)
+
+## Table of contents
+
+1. Design guidelines
+2. Development guidelines
+
+## Design guidelines
+
+### Usage
+
+Tabs organizes and allows navigation between groups of content that are related and at the same level of hierarchy.
+
+#### Tabs in a set
+
+As a set, all individual tab items are unified by a shared topic. For clarity, each tab item should contain content that is distinct from all the other tabs in a set.
+
+### Anatomy
+
+![](https://wordpress.org/gutenberg/files/2019/01/s_E36D9C9B8FFA15A1A8CE224E422535A12B016F88884089575F9998E52016A49F_1541787297310_TabPanelAnatomy.png)
+
+1. Container
+2. Active text label
+3. Active tab indicator
+4. Inactive text label
+5. Tab item
+
+#### Labels
+
+Tab labels appear in a single row, in the same typeface and size. Use text labels that clearly and succinctly describe the content of a tab, and make sure that a set of tabs contains a cohesive group of items that share a common characteristic.
+
+Tab labels can wrap to a second line, but do not add a second row of tabs.
+
+#### Active tab indicators
+
+To differentiate an active tab from an inactive tab, apply an underline and color change to the active tab’s text and icon.
+
+![An underline and color change differentiate an active tab from the inactive ones.](https://wordpress.org/gutenberg/files/2019/01/s_E36D9C9B8FFA15A1A8CE224E422535A12B016F88884089575F9998E52016A49F_1541787691601_TabPanelActiveTab.png)
+
+### Behavior
+
+Users can navigate between tabs by clicking the desired tab with their mouse. They can also tap the tab key on their keyboard to focus the `tablist`, and then navigate between tabs by tapping the arrow keys on their keyboard.
+
+### Placement
+
+Tabs are generally placed above content, allowing them to control the UI region displayed below them. It is also possible to render the tabs or the content elsewhere in the UI, using a `SlotFill` component when necessary.
+
+## 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 selected tab is specified by a parent component. In this mode, the `initialTabId` prop will be ignored if it is provided. Instead, the `selectedTabId` value will be used to determine the selected tab. 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.
+
+```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.
+- `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
+
+###### `activeClass`: `string`
+
+The class to add to the active tab
+
+- Required: No
+- Default: `is-active`
+
+###### `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
+- Default: none
+
+###### `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`
+
+#### 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
+
+###### `title`: `string`
+
+The label for the tab.
+
+- 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
+
+###### `icon`: `IconType`
+
+The icon used for the tab button.
+
+- Required: No
+
+###### `disabled`: `boolean`
+
+Determines if the tab button should be disabled.
+
+- Required: No
+- Default: `false`
+
+#### 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`
+
+###### `className`: `string`
+
+The class name to apply to the tabpanel.
+
+- Required: No
+
+###### `style`: `React.CSSProperties`
+
+Custom CSS styles for the tab.
+
+- Required: No
From aabbf401faa5c9e44f1f85d4458b0363a70be2a3 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Thu, 14 Sep 2023 14:18:51 -0400
Subject: [PATCH 08/42] incorporate initial doc feedback
---
packages/components/src/tabs/README.md | 9 ++++++++-
packages/components/src/tabs/types.ts | 5 ++---
2 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md
index e9f447e86e8ef..cc8d5dc72d941 100644
--- a/packages/components/src/tabs/README.md
+++ b/packages/components/src/tabs/README.md
@@ -177,7 +177,6 @@ The id of the tab to be selected upon mounting of component. If this prop is not
_Note: this prop will be overridden by the `selectedTabId` prop if it is provided. (Controlled Mode)_
- Required: No
-- Default: none
###### `onSelect`: `( ( selectedId: string | null | undefined ) => void )`
@@ -193,6 +192,12 @@ The orientation of the `tablist` (`vertical` or `horizontal`)
- Required: No
- Default: `horizontal`
+###### `selectedTabId`: `string | null | undefined`
+
+The ID of the tab to display. This id is prepended with the `Tabs` instanceId internally.
+This prop puts the component into controlled mode. A value of `null` will result in no tab being selected.
+- Required: No
+
#### TabList
##### Props
@@ -277,6 +282,8 @@ The children elements, generally the content to display on the tabpanel.
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.
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
index 675fbe92ab9cd..09941f506db89 100644
--- a/packages/components/src/tabs/types.ts
+++ b/packages/components/src/tabs/types.ts
@@ -77,7 +77,6 @@ export type TabsProps = {
* instanceId internally.
*
* This prop puts the component into controlled mode. A value of
- * `undefined` returns the component to uncontrolled mode. A value of
* `null` will result in no tab being selected.
*/
selectedTabId?: string | null;
@@ -85,7 +84,7 @@ export type TabsProps = {
export type TabListProps = {
/**
- * The children elements
+ * The children elements, which should be a series of `Tabs.TabPanel` components.
*/
children?: React.ReactNode;
/**
@@ -133,7 +132,7 @@ export type TabProps = {
export type TabPanelProps = {
/**
- * The children elements
+ * The children elements, generally the content to display on the tabpanel.
*/
children?: React.ReactNode;
/**
From 59b1387f81c82fa98661166625a0773c042b3ded Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Thu, 14 Sep 2023 16:08:05 -0400
Subject: [PATCH 09/42] update for Tootip/Popover.Slot changes
---
.../src/tabs/stories/index.story.tsx | 43 ++++-----
packages/components/src/tabs/test/index.tsx | 96 ++++++++-----------
2 files changed, 58 insertions(+), 81 deletions(-)
diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx
index 481079c45f0e4..559d467d85c82 100644
--- a/packages/components/src/tabs/stories/index.story.tsx
+++ b/packages/components/src/tabs/stories/index.story.tsx
@@ -13,7 +13,6 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import Tabs from '..';
-import Popover from '../../popover';
import { Slot, Fill, Provider as SlotFillProvider } from '../../slot-fill';
import DropdownMenu from '../../dropdown-menu';
import Button from '../../button';
@@ -88,32 +87,22 @@ export const DisabledTab = DisabledTabTemplate.bind( {} );
const WithTabIconsAndTooltipsTemplate: StoryFn< typeof Tabs > = ( props ) => {
return (
- // SlotFill is used here to ensure the icon's tooltips are not
- // rendered inline, as that would cause them to inherit the tab's opacity.
-
-
-
-
-
-
-
-
- Selected tab: Tab 1
-
-
- Selected tab: Tab 2
-
-
- Selected tab: Tab 3
-
-
- { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ }
-
-
+
+
+
+
+
+
+
+ Selected tab: Tab 1
+
+
+ Selected tab: Tab 2
+
+
+ Selected tab: Tab 3
+
+
);
};
export const WithTabIconsAndTooltips = WithTabIconsAndTooltipsTemplate.bind(
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index c26676d6dd680..130d15da9a283 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -14,14 +14,12 @@ import { useState } from '@wordpress/element';
* Internal dependencies
*/
import Tabs from '..';
-import Popover from '../../popover';
-import { Provider as SlotFillProvider } from '../../slot-fill';
import type { TabsProps } from '../types';
+import cleanupTooltip from '../../tooltip/test/utils';
const UncontrolledTabs = ( props?: Omit< TabsProps, 'children' | 'tabs' > ) => {
return (
- // Force `selectedTabId` to `undefined` to maintain uncontrolled mode
-
+
{ TABS.map( ( tab ) => (
{
const user = userEvent.setup();
render(
- // In order for the tooltip to display properly, there needs to be
- // `Popover.Slot` in which the `Popover` renders outside of the
- // `Tabs` component, otherwise the tooltip renders inline.
-
-
-
- { TABS.map( ( tab ) => (
-
- ) ) }
-
+
+
{ TABS.map( ( tab ) => (
-
- { tab.content }
-
+
) ) }
-
- { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ }
-
-
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
);
const allTabs = screen.getAllByRole( 'tab' );
@@ -188,6 +179,8 @@ describe( 'Tabs', () => {
await user.unhover( allTabs[ i ] );
}
+
+ await cleanupTooltip( user );
} );
it( 'should display a tooltip when moving the selection via the keyboard on tabs provided with an icon', async () => {
@@ -196,31 +189,24 @@ describe( 'Tabs', () => {
const mockOnSelect = jest.fn();
render(
- // In order for the tooltip to display properly, there needs to be
- // `Popover.Slot` in which the `Popover` renders outside of the
- // `Tabs` component, otherwise the tooltip renders inline.
-
-
-
- { TABS.map( ( tab ) => (
-
- ) ) }
-
+
+
{ TABS.map( ( tab ) => (
-
- { tab.content }
-
+
) ) }
-
- { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ }
-
-
+
+ { TABS.map( ( tab ) => (
+
+ { tab.content }
+
+ ) ) }
+
);
expect( await getSelectedTab() ).not.toHaveTextContent( 'Alpha' );
@@ -233,7 +219,7 @@ describe( 'Tabs', () => {
expect( screen.queryByText( 'Alpha' ) ).not.toBeInTheDocument();
await user.keyboard( '[Tab]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- expect( screen.getByText( 'Alpha' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Alpha' ) ).toBeVisible();
expect( await getSelectedTab() ).toHaveFocus();
// Move selection with arrow keys. Make sure beta is focused, and that
@@ -242,7 +228,7 @@ describe( 'Tabs', () => {
await user.keyboard( '[ArrowRight]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
- expect( screen.getByText( 'Beta' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Beta' ) ).toBeVisible();
expect( await getSelectedTab() ).toHaveFocus();
// Move selection with arrow keys. Make sure gamma is focused, and that
@@ -251,7 +237,7 @@ describe( 'Tabs', () => {
await user.keyboard( '[ArrowRight]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 3 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' );
- expect( screen.getByText( 'Gamma' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Gamma' ) ).toBeVisible();
expect( await getSelectedTab() ).toHaveFocus();
// Move selection with arrow keys. Make sure beta is focused, and that
@@ -260,8 +246,10 @@ describe( 'Tabs', () => {
await user.keyboard( '[ArrowLeft]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 4 );
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
- expect( screen.getByText( 'Beta' ) ).toBeInTheDocument();
+ expect( screen.getByText( 'Beta' ) ).toBeVisible();
expect( await getSelectedTab() ).toHaveFocus();
+
+ await cleanupTooltip( user );
} );
} );
describe( 'Tab Attributes', () => {
From 00b9e422b4fc72257096b9840c2695edf1d64480 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Thu, 14 Sep 2023 16:08:19 -0400
Subject: [PATCH 10/42] docs manifest
---
docs/manifest.json | 6 ++++++
1 file changed, 6 insertions(+)
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",
From ab092334142111c9c1de738071cdc230e84dfc99 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Thu, 14 Sep 2023 22:57:45 -0400
Subject: [PATCH 11/42] create individual files for each sub-component
---
packages/components/src/tabs/context.ts | 13 +++++
packages/components/src/tabs/tab.tsx | 59 +++++++++++++++++++++++
packages/components/src/tabs/tablist.tsx | 36 ++++++++++++++
packages/components/src/tabs/tabpanel.tsx | 44 +++++++++++++++++
4 files changed, 152 insertions(+)
create mode 100644 packages/components/src/tabs/context.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
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/tab.tsx b/packages/components/src/tabs/tab.tsx
new file mode 100644
index 0000000000000..97088b8148bcc
--- /dev/null
+++ b/packages/components/src/tabs/tab.tsx
@@ -0,0 +1,59 @@
+/**
+ * External dependencies
+ */
+import * as Ariakit from '@ariakit/react';
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+
+import { useContext } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import type { TabProps } from './types';
+import Button from '../button';
+import warning from '@wordpress/warning';
+import { TabsContext } from './context';
+
+function Tab( {
+ children,
+ id,
+ className,
+ disabled,
+ icon,
+ title,
+ style,
+}: TabProps ) {
+ const context = useContext( TabsContext );
+ if ( ! context ) {
+ warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
+ return null;
+ }
+ const { store, instanceId, activeClass } = context;
+ const instancedTabId = `${ instanceId }-${ id }`;
+ return (
+
+ }
+ >
+ { children }
+
+ );
+}
+
+export default Tab;
diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx
new file mode 100644
index 0000000000000..133f467e659e7
--- /dev/null
+++ b/packages/components/src/tabs/tablist.tsx
@@ -0,0 +1,36 @@
+/**
+ * External dependencies
+ */
+import * as Ariakit from '@ariakit/react';
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+import warning from '@wordpress/warning';
+
+/**
+ * Internal dependencies
+ */
+import type { TabListProps } from './types';
+import { useTabsContext } from './context';
+
+function TabList( { children, className, style }: TabListProps ) {
+ const context = useTabsContext();
+ if ( ! context ) {
+ warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
+ return null;
+ }
+ const { store } = context;
+ return (
+
+ { children }
+
+ );
+}
+
+export default TabList;
diff --git a/packages/components/src/tabs/tabpanel.tsx b/packages/components/src/tabs/tabpanel.tsx
new file mode 100644
index 0000000000000..544d99ae03cf2
--- /dev/null
+++ b/packages/components/src/tabs/tabpanel.tsx
@@ -0,0 +1,44 @@
+/**
+ * External dependencies
+ */
+import * as Ariakit from '@ariakit/react';
+import classnames from 'classnames';
+
+/**
+ * WordPress dependencies
+ */
+
+import { useContext } from '@wordpress/element';
+
+/**
+ * Internal dependencies
+ */
+import type { TabPanelProps } from './types';
+
+import warning from '@wordpress/warning';
+import { TabsContext } from './context';
+
+function TabPanel( { children, id, className, style }: TabPanelProps ) {
+ const context = useContext( TabsContext );
+ if ( ! context ) {
+ warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' );
+ return null;
+ }
+ const { store, instanceId } = context;
+
+ return (
+
+ { children }
+
+ );
+}
+
+export default TabPanel;
From 1c2f9e52e43f2b1c36a0a63f17f991f284e1d1b4 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Thu, 14 Sep 2023 22:58:14 -0400
Subject: [PATCH 12/42] update index to use individual subcomponent files
---
packages/components/src/tabs/index.tsx | 145 +------------------------
1 file changed, 6 insertions(+), 139 deletions(-)
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index cd224055715f3..50cc21e0c7700 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -2,75 +2,21 @@
* External dependencies
*/
import * as Ariakit from '@ariakit/react';
-import classnames from 'classnames';
/**
* WordPress dependencies
*/
import { useInstanceId } from '@wordpress/compose';
-import {
- createContext,
- useContext,
- useEffect,
- useLayoutEffect,
-} from '@wordpress/element';
+import { useEffect, useLayoutEffect } from '@wordpress/element';
/**
* Internal dependencies
*/
-import type {
- TabListProps,
- TabPanelProps,
- TabProps,
- TabsContextProps,
- TabsProps,
-} from './types';
-import Button from '../button';
-import warning from '@wordpress/warning';
-
-const TabsContext = createContext< TabsContextProps >( undefined );
-
-/**
- * Tabs is a collection of React components that combine to render an
- * ARIA-compliant TabPanel.
- *
- * 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.
- *
- * ```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
- *
- *
- * );
- * ```
- *
- */
+import type { TabsProps } from './types';
+import { TabsContext } from './context';
+import Tab from './tab';
+import TabList from './tablist';
+import TabPanel from './tabpanel';
function Tabs( {
activeClass = 'is-active',
@@ -171,85 +117,6 @@ function Tabs( {
);
}
-function TabList( { children, className, style }: TabListProps ) {
- const context = useContext( TabsContext );
- if ( ! context ) {
- warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
- return null;
- }
- const { store } = context;
- return (
-
- { children }
-
- );
-}
-
-function Tab( {
- children,
- id,
- className,
- disabled,
- icon,
- title,
- style,
-}: TabProps ) {
- const context = useContext( TabsContext );
- if ( ! context ) {
- warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
- return null;
- }
- const { store, instanceId, activeClass } = context;
- const instancedTabId = `${ instanceId }-${ id }`;
- return (
-
- }
- >
- { children }
-
- );
-}
-
-function TabPanel( { children, id, className, style }: TabPanelProps ) {
- const context = useContext( TabsContext );
- if ( ! context ) {
- warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' );
- return null;
- }
- const { store, instanceId } = context;
-
- return (
-
- { children }
-
- );
-}
-
Tabs.TabList = TabList;
Tabs.Tab = Tab;
Tabs.TabPanel = TabPanel;
From 0b955270e11ea1369f5636a2f0daa01eca1c62bb Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Wed, 20 Sep 2023 10:42:56 -0400
Subject: [PATCH 13/42] fix mystyped class name on TabList
---
packages/components/src/tabs/tablist.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx
index 133f467e659e7..9d8f8257c30d7 100644
--- a/packages/components/src/tabs/tablist.tsx
+++ b/packages/components/src/tabs/tablist.tsx
@@ -26,7 +26,7 @@ function TabList( { children, className, style }: TabListProps ) {
{ children }
From c8b92d60e63e28e3790db809601d5241d1d29b37 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Wed, 20 Sep 2023 15:32:28 -0400
Subject: [PATCH 14/42] clean up unit tests
---
packages/components/src/tabs/test/index.tsx | 1370 ++++++-------------
1 file changed, 421 insertions(+), 949 deletions(-)
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index 130d15da9a283..4c6ebeff94b9b 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -16,51 +16,20 @@ import { useState } from '@wordpress/element';
import Tabs from '..';
import type { TabsProps } from '../types';
import cleanupTooltip from '../../tooltip/test/utils';
-
-const UncontrolledTabs = ( props?: Omit< TabsProps, 'children' | 'tabs' > ) => {
- return (
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+import type { IconType } from '../../icon';
+
+type Tab = {
+ id: string;
+ title: string;
+ content: React.ReactNode;
+ tab: {
+ className?: string;
+ icon?: IconType;
+ disabled?: boolean;
+ };
};
-const ControlledTabs = ( props: TabsProps ) => {
- const [ selectedTabId, setSelectedTabId ] = useState<
- string | undefined | null
- >( props?.selectedTabId );
-
- return (
- {
- setSelectedTabId( selectedId );
- props?.onSelect?.( selectedId );
- } }
- >
- { props.children }
-
- );
-};
-
-const TABS = [
+const TABS: Tab[] = [
{
id: 'alpha',
title: 'Alpha',
@@ -81,7 +50,7 @@ const TABS = [
},
];
-const TABS_WITH_DELTA = [
+const TABS_WITH_DELTA: Tab[] = [
...TABS,
{
id: 'delta',
@@ -91,6 +60,83 @@ const TABS_WITH_DELTA = [
},
];
+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 } );
@@ -114,7 +160,7 @@ describe( 'Tabs', () => {
describe( 'Accessibility and semantics', () => {
it( 'should use the correct aria attributes', async () => {
- render( );
+ render( );
const tabList = screen.getByRole( 'tablist' );
const allTabs = screen.getAllByRole( 'tab' );
@@ -144,26 +190,7 @@ describe( 'Tabs', () => {
it( 'should display a tooltip when hovering tabs provided with an icon', async () => {
const user = userEvent.setup();
- render(
-
-
- { TABS.map( ( tab ) => (
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+ render( );
const allTabs = screen.getAllByRole( 'tab' );
for ( let i = 0; i < allTabs.length; i++ ) {
@@ -189,24 +216,11 @@ describe( 'Tabs', () => {
const mockOnSelect = jest.fn();
render(
-
-
- { TABS.map( ( tab ) => (
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).not.toHaveTextContent( 'Alpha' );
@@ -252,9 +266,10 @@ describe( 'Tabs', () => {
await cleanupTooltip( user );
} );
} );
+
describe( 'Tab Attributes', () => {
it( "should apply the tab's `className` to the tab button", async () => {
- render( );
+ render( );
expect(
await screen.findByRole( 'tab', { name: 'Alpha' } )
@@ -271,7 +286,9 @@ describe( 'Tabs', () => {
const user = userEvent.setup();
const activeClass = 'my-active-tab';
- render( );
+ render(
+
+ );
// Make sure that only the selected tab has the active class
expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
@@ -301,7 +318,9 @@ describe( 'Tabs', () => {
const user = userEvent.setup();
const mockOnSelect = jest.fn();
- render( );
+ render(
+
+ );
// Alpha is the initially selected tab
expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
@@ -333,7 +352,9 @@ describe( 'Tabs', () => {
const user = userEvent.setup();
const mockOnSelect = jest.fn();
- render( );
+ render(
+
+ );
// onSelect gets called on the initial render.
expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
@@ -365,7 +386,9 @@ describe( 'Tabs', () => {
const user = userEvent.setup();
const mockOnSelect = jest.fn();
- render( );
+ render(
+
+ );
// onSelect gets called on the initial render.
expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
@@ -398,7 +421,7 @@ describe( 'Tabs', () => {
const mockOnSelect = jest.fn();
const { rerender } = render(
-
+
);
// onSelect gets called on the initial render.
@@ -428,6 +451,7 @@ describe( 'Tabs', () => {
// left/right arrow keys are replaced by up/down arrow keys.
rerender(
@@ -479,28 +503,23 @@ describe( 'Tabs', () => {
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(
-
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// onSelect gets called on the initial render.
@@ -546,7 +565,7 @@ describe( 'Tabs', () => {
it( 'should not focus the next tab when the Tab key is pressed', async () => {
const user = userEvent.setup();
- render( );
+ render( );
// Tab should initially focus the first tab in the tablist, which
// is Alpha.
@@ -569,6 +588,7 @@ describe( 'Tabs', () => {
render(
@@ -614,7 +634,7 @@ describe( 'Tabs', () => {
describe( 'Uncontrolled mode', () => {
describe( 'Without `initialTabId` prop', () => {
it( 'should render first tab', async () => {
- render( );
+ render( );
expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
expect(
@@ -623,61 +643,31 @@ describe( 'Tabs', () => {
} );
it( 'should fall back to first enabled tab if the active tab is removed', async () => {
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
- rerender(
-
-
- { /* Remove alpha */ }
- { TABS.slice( 1 ).map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.slice( 1 ).map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+ // Remove first item from `TABS` array
+ rerender( );
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
} );
} );
+
describe( 'With `initialTabId`', () => {
it( 'should render the tab set by `initialTabId` prop', async () => {
- render( );
+ render(
+
+ );
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
} );
it( 'should not select a tab when `initialTabId` does not match any known tab', () => {
- render( );
+ render(
+
+ );
// No tab should be selected i.e. it doesn't fall back to first tab.
expect(
@@ -691,10 +681,12 @@ describe( 'Tabs', () => {
} );
it( 'should not change tabs when initialTabId is changed', async () => {
const { rerender } = render(
-
+
);
- rerender( );
+ rerender(
+
+ );
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
} );
@@ -704,25 +696,11 @@ describe( 'Tabs', () => {
const mockOnSelect = jest.fn();
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
@@ -734,26 +712,11 @@ describe( 'Tabs', () => {
expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );
rerender(
-
-
- { /* Remove alpha */ }
- { TABS.slice( 1 ).map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.slice( 1 ).map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
@@ -761,50 +724,17 @@ describe( 'Tabs', () => {
it( 'should have no active tabs when the tab associated to `initialTabId` is removed while being the active tab', async () => {
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+ // Remove gamma
rerender(
-
-
- { /* Remove gamma */ }
- { TABS.slice( 0, 2 ).map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.slice( 0, 2 ).map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 );
@@ -820,25 +750,7 @@ describe( 'Tabs', () => {
it( 'waits for the tab with the `initialTabId` to be present in the `tabs` array before selecting it', async () => {
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// There should be no selected tab yet.
@@ -847,57 +759,39 @@ describe( 'Tabs', () => {
).not.toBeInTheDocument();
rerender(
-
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
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(
-
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect(
@@ -906,36 +800,40 @@ describe( 'Tabs', () => {
// 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
+ await waitFor( () =>
+ 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(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// As alpha (first tab) is disabled,
@@ -943,27 +841,7 @@ describe( 'Tabs', () => {
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
// Re-enable all tabs
- rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+ rerender( );
// Even if the initial tab becomes enabled again, the selected
// tab doesn't change.
@@ -971,27 +849,22 @@ describe( 'Tabs', () => {
} );
it( 'should select first enabled tab when the tab associated to `initialTabId` is disabled', async () => {
+ const TABS_ONLY_BETA_ENABLED = TABS.map( ( tabObj ) =>
+ tabObj.id !== 'gamma'
+ ? {
+ ...tabObj,
+ tab: {
+ ...tabObj.tab,
+ disabled: true,
+ },
+ }
+ : tabObj
+ );
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// As alpha (first tab), and beta (the initial tab), are both
@@ -1000,25 +873,7 @@ describe( 'Tabs', () => {
// Re-enable all tabs
rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// Even if the initial tab becomes enabled again, the selected tab doesn't
@@ -1029,53 +884,31 @@ describe( 'Tabs', () => {
it( 'should select the first enabled tab when the selected tab becomes disabled', async () => {
const mockOnSelect = jest.fn();
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
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(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
@@ -1084,25 +917,7 @@ describe( 'Tabs', () => {
// Re-enable all tabs
rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
@@ -1114,51 +929,34 @@ describe( 'Tabs', () => {
const mockOnSelect = jest.fn();
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
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(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
@@ -1167,25 +965,11 @@ describe( 'Tabs', () => {
// Re-enable all tabs
rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
@@ -1197,25 +981,10 @@ describe( 'Tabs', () => {
describe( 'Controlled mode', () => {
it( 'should not render any tab if `selectedTabId` does not match any known tab', () => {
render(
-
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// No tab should be selected i.e. it doesn't fall back to first tab.
@@ -1226,27 +995,24 @@ describe( 'Tabs', () => {
expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument();
} );
it( 'should not render any tab if `selectedTabId` refers to an 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(
-
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// No tab should be selected i.e. it doesn't fall back to first tab.
@@ -1260,27 +1026,7 @@ describe( 'Tabs', () => {
} );
describe( 'Without `initialTabId` prop', () => {
it( 'should render the tab specified by the `specifiedTabId` prop', async () => {
- render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
- );
+ render( );
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
expect(
@@ -1289,52 +1035,14 @@ describe( 'Tabs', () => {
} );
it( 'should not render any tab if the active tab is removed', async () => {
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
+ // Remove
rerender(
-
-
- { /* Remove beta */ }
- { TABS.filter( ( tab ) => tab.id !== 'beta' ).map(
- ( tab ) => (
-
- { tab.title }
-
- )
- ) }
-
- { TABS.filter( ( tab ) => tab.id !== 'beta' ).map(
- ( tab ) => (
-
- { tab.content }
-
- )
- ) }
-
+ tab.id !== 'beta' ) }
+ />
);
expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 );
@@ -1352,25 +1060,11 @@ describe( 'Tabs', () => {
describe( 'With `initialTabId`', () => {
it( 'should render the specified `selectedTabId`, and ignore the `initialTabId` prop', async () => {
render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
@@ -1378,27 +1072,10 @@ describe( 'Tabs', () => {
it( 'should render the specified `selectedTabId` when `initialTabId` does not match any known tab', async () => {
render(
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+ />
);
// No tab should be selected i.e. it doesn't fall back to first tab.
@@ -1406,101 +1083,41 @@ describe( 'Tabs', () => {
} );
it( 'should not change tabs when initialTabId is changed', async () => {
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
} );
it( 'should not render any tab if the currently active tab is removed', async () => {
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ // Remove beta
rerender(
-
-
- { /* Remove beta */ }
- { TABS.filter( ( tab ) => tab.id !== 'beta' ).map(
- ( tab ) => (
-
- { tab.title }
-
- )
- ) }
-
- { TABS.filter( ( tab ) => tab.id !== 'beta' ).map(
- ( tab ) => (
-
- { tab.content }
-
- )
- ) }
-
+ tab.id !== 'beta' ) }
+ selectedTabId="beta"
+ initialTabId="gamma"
+ />
);
expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 );
@@ -1515,50 +1132,22 @@ describe( 'Tabs', () => {
} );
it( 'should have no active tabs when the tab associated to `initialTabId` is removed while being the active tab', async () => {
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+ // Remove gamma
rerender(
-
-
- { /* Remove gamma */ }
- { TABS.slice( 0, 2 ).map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 );
@@ -1573,50 +1162,23 @@ describe( 'Tabs', () => {
} );
it( 'does not select `initialTabId` if it becomes available after the initial render', async () => {
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// The controlled tab, Beta, should be selected.
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ // Re-render with the tab associated to `initialTabId` available.
rerender(
-
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS_WITH_DELTA.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// Beta should remain selected, even after the `initialTabId` of Delta becomes available.
@@ -1625,52 +1187,35 @@ describe( 'Tabs', () => {
} );
describe( 'Disabled tab', () => {
it( 'should render the specified `selectedTabId` (not the first enabled tab) when the tab associated to `initialTabId` is disabled', async () => {
+ const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) =>
+ tabObj.id === 'beta'
+ ? {
+ ...tabObj,
+ tab: {
+ ...tabObj.tab,
+ disabled: true,
+ },
+ }
+ : tabObj
+ );
+
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
// Re-enable all tabs
rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// Even if the initial tab becomes enabled again, the selected tab doesn't
@@ -1679,51 +1224,28 @@ describe( 'Tabs', () => {
} );
it( 'should not render any tab when the selected tab becomes disabled', async () => {
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
+ const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) =>
+ tabObj.id === 'beta'
+ ? {
+ ...tabObj,
+ tab: {
+ ...tabObj.tab,
+ disabled: true,
+ },
+ }
+ : tabObj
+ );
+
rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// No tab should be selected i.e. it doesn't fall back to first tab.
// `waitFor` is needed here to prevent testing library from
@@ -1740,25 +1262,7 @@ describe( 'Tabs', () => {
// re-enable all tabs
rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// If the previously selected tab is reenabled, it should not
@@ -1773,51 +1277,33 @@ describe( 'Tabs', () => {
} );
it( 'should not render any tab when the tab associated to `initialTabId` becomes disabled while being the active tab', async () => {
const { rerender } = render(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
+ const TABS_WITH_GAMMA_DISABLED = TABS.map( ( tabObj ) =>
+ tabObj.id === 'gamma'
+ ? {
+ ...tabObj,
+ tab: {
+ ...tabObj.tab,
+ disabled: true,
+ },
+ }
+ : tabObj
+ );
+
rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// No tab should be selected i.e. it doesn't fall back to first tab.
@@ -1835,25 +1321,11 @@ describe( 'Tabs', () => {
// re-enable all tabs
rerender(
-
-
- { TABS.map( ( tab ) => (
-
- { tab.title }
-
- ) ) }
-
- { TABS.map( ( tab ) => (
-
- { tab.content }
-
- ) ) }
-
+
);
// If the previously selected tab is reenabled, it should not
From 1ff0685fa202e060c66a7474c42d5de33473b46c Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Wed, 20 Sep 2023 16:25:00 -0400
Subject: [PATCH 15/42] shore up unit tests by adding additional
`toHaveBeenLastCalledWith` assertions
---
packages/components/src/tabs/test/index.tsx | 26 +++++++++++++++------
1 file changed, 19 insertions(+), 7 deletions(-)
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index 4c6ebeff94b9b..a44cf45334e94 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -356,8 +356,10 @@ describe( 'Tabs', () => {
);
- // onSelect gets called on the initial 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' );
@@ -424,8 +426,10 @@ describe( 'Tabs', () => {
);
- // onSelect gets called on the initial 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' );
@@ -522,14 +526,17 @@ describe( 'Tabs', () => {
/>
);
- // onSelect gets called on the initial 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:
@@ -594,9 +601,10 @@ describe( 'Tabs', () => {
/>
);
- // onSelect gets called on the initial render with the default
- // selected tab.
+ // 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.
@@ -606,7 +614,8 @@ describe( 'Tabs', () => {
// Navigate forward with arrow keys. Make sure Beta is focused, but
// that the tab selection happens only when pressing the spacebar
- // or enter key.
+ // or enter key. onSelect shouldn't fire since the selected tab
+ // didn't change.
await user.keyboard( '[ArrowRight]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
expect(
@@ -619,7 +628,8 @@ describe( 'Tabs', () => {
// 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.
+ // spacebar or enter key. onSelect shouldn't fire since the selected
+ // tab didn't change.
await user.keyboard( '[ArrowRight]' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
expect(
@@ -972,6 +982,8 @@ describe( 'Tabs', () => {
/>
);
+ // Confirm that alpha is still selected, and that onSelect has
+ // not been called again.
expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
} );
From e53b79267633a6fc5f20246e67ede72b28dfa693 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Wed, 20 Sep 2023 16:45:50 -0400
Subject: [PATCH 16/42] README improvements
---
packages/components/src/tabs/README.md | 53 ++------------------------
1 file changed, 3 insertions(+), 50 deletions(-)
diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md
index cc8d5dc72d941..d34122b5c97e8 100644
--- a/packages/components/src/tabs/README.md
+++ b/packages/components/src/tabs/README.md
@@ -1,56 +1,9 @@
-# TabPanel
+# Tabs
-Tabs is a collection of React components that combine to render an ARIA-compliant TabPanel.
+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.
-![The “Document” tab selected in the sidebar TabPanel.](https://wordpress.org/gutenberg/files/2019/01/s_E36D9C9B8FFA15A1A8CE224E422535A12B016F88884089575F9998E52016A49F_1541785098230_TabPanel.png)
-
-## Table of contents
-
-1. Design guidelines
-2. Development guidelines
-
-## Design guidelines
-
-### Usage
-
-Tabs organizes and allows navigation between groups of content that are related and at the same level of hierarchy.
-
-#### Tabs in a set
-
-As a set, all individual tab items are unified by a shared topic. For clarity, each tab item should contain content that is distinct from all the other tabs in a set.
-
-### Anatomy
-
-![](https://wordpress.org/gutenberg/files/2019/01/s_E36D9C9B8FFA15A1A8CE224E422535A12B016F88884089575F9998E52016A49F_1541787297310_TabPanelAnatomy.png)
-
-1. Container
-2. Active text label
-3. Active tab indicator
-4. Inactive text label
-5. Tab item
-
-#### Labels
-
-Tab labels appear in a single row, in the same typeface and size. Use text labels that clearly and succinctly describe the content of a tab, and make sure that a set of tabs contains a cohesive group of items that share a common characteristic.
-
-Tab labels can wrap to a second line, but do not add a second row of tabs.
-
-#### Active tab indicators
-
-To differentiate an active tab from an inactive tab, apply an underline and color change to the active tab’s text and icon.
-
-![An underline and color change differentiate an active tab from the inactive ones.](https://wordpress.org/gutenberg/files/2019/01/s_E36D9C9B8FFA15A1A8CE224E422535A12B016F88884089575F9998E52016A49F_1541787691601_TabPanelActiveTab.png)
-
-### Behavior
-
-Users can navigate between tabs by clicking the desired tab with their mouse. They can also tap the tab key on their keyboard to focus the `tablist`, and then navigate between tabs by tapping the arrow keys on their keyboard.
-
-### Placement
-
-Tabs are generally placed above content, allowing them to control the UI region displayed below them. It is also possible to render the tabs or the content elsewhere in the UI, using a `SlotFill` component when necessary.
-
## Development guidelines
### Usage
@@ -94,7 +47,7 @@ const MyUncontrolledTabs = () => (
#### Controlled Mode
-Tabs can also be used in a controlled mode, where the selected tab is specified by a parent component. In this mode, the `initialTabId` prop will be ignored if it is provided. Instead, the `selectedTabId` value will be used to determine the selected tab. 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.
+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';
From ed776b3f913d543d9c076e5216968caccdd9130e Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Thu, 21 Sep 2023 17:37:44 -0400
Subject: [PATCH 17/42] transition to emotion
---
packages/components/src/tabs/style.scss | 83 --------------------
packages/components/src/tabs/styles.ts | 96 +++++++++++++++++++++++
packages/components/src/tabs/tab.tsx | 6 +-
packages/components/src/tabs/tablist.tsx | 6 +-
packages/components/src/tabs/tabpanel.tsx | 6 +-
5 files changed, 103 insertions(+), 94 deletions(-)
delete mode 100644 packages/components/src/tabs/style.scss
create mode 100644 packages/components/src/tabs/styles.ts
diff --git a/packages/components/src/tabs/style.scss b/packages/components/src/tabs/style.scss
deleted file mode 100644
index df42638b3963d..0000000000000
--- a/packages/components/src/tabs/style.scss
+++ /dev/null
@@ -1,83 +0,0 @@
-.components-tabs__tabs {
- display: flex;
- align-items: stretch;
- flex-direction: row;
- &[aria-orientation="vertical"] {
- flex-direction: column;
- }
-}
-
-// This tab style CSS is duplicated verbatim in
-// /packages/edit-post/src/components/sidebar/settings-header/style.scss
-.components-tabs__tabs-item {
- position: relative;
- border-radius: 0;
- height: $grid-unit-60;
- background: transparent;
- border: none;
- box-shadow: none;
- cursor: pointer;
- padding: 3px $grid-unit-20; // Use padding to offset the is-active border, this benefits Windows High Contrast mode
- margin-left: 0;
- font-weight: 500;
-
- &: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: $components-color-accent;
- height: calc(0 * var(--wp-admin-border-width-focus));
- border-radius: 0;
-
- // Animation
- transition: all 0.1s linear;
- @include reduce-motion("transition");
- }
-
- // Active.
- &.is-active::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: $grid-unit-15;
- right: $grid-unit-15;
- bottom: $grid-unit-15;
- left: $grid-unit-15;
- pointer-events: none;
-
- // Draw the indicator.
- box-shadow: 0 0 0 0 transparent;
- border-radius: $radius-block-ui;
-
- // Animation
- transition: all 0.1s linear;
- @include reduce-motion("transition");
- }
-
- &:focus-visible::before {
- box-shadow: 0 0 0 var(--wp-admin-border-width-focus) $components-color-accent;
-
- // Windows high contrast mode.
- outline: 2px solid transparent;
- }
-}
diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts
new file mode 100644
index 0000000000000..dd68acded4cc6
--- /dev/null
+++ b/packages/components/src/tabs/styles.ts
@@ -0,0 +1,96 @@
+/**
+ * External dependencies
+ */
+import styled from '@emotion/styled';
+
+/**
+ * Internal dependencies
+ */
+import Button from '../button';
+import { COLORS } from '../utils';
+import { space } from '../ui/utils/space';
+
+export const TabListWrapper = styled.div`
+ display: flex;
+ align-items: stretch;
+ flex-direction: row;
+ &[aria-orientation='vertical'] {
+ flex-direction: column;
+ }
+`;
+
+export const TabButton = styled( Button )`
+ && {
+ 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 is-active border, this benefits Windows High Contrast mode
+ margin-left: 0;
+ font-weight: 500;
+
+ &: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;
+ @include reduce-motion( 'transition' );
+ }
+
+ // Active.
+ &.is-active::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;
+ @include reduce-motion( '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
index 97088b8148bcc..4ef466d9b61ca 100644
--- a/packages/components/src/tabs/tab.tsx
+++ b/packages/components/src/tabs/tab.tsx
@@ -14,9 +14,9 @@ import { useContext } from '@wordpress/element';
* Internal dependencies
*/
import type { TabProps } from './types';
-import Button from '../button';
import warning from '@wordpress/warning';
import { TabsContext } from './context';
+import { TabButton } from './styles';
function Tab( {
children,
@@ -38,13 +38,13 @@ function Tab( {
}
>
{ children }
diff --git a/packages/components/src/tabs/tabpanel.tsx b/packages/components/src/tabs/tabpanel.tsx
index 544d99ae03cf2..9068a55bf02f3 100644
--- a/packages/components/src/tabs/tabpanel.tsx
+++ b/packages/components/src/tabs/tabpanel.tsx
@@ -2,7 +2,6 @@
* External dependencies
*/
import * as Ariakit from '@ariakit/react';
-import classnames from 'classnames';
/**
* WordPress dependencies
@@ -31,10 +30,7 @@ function TabPanel( { children, id, className, style }: TabPanelProps ) {
style={ style }
store={ store }
id={ `${ instanceId }-${ id }-view` }
- className={ classnames(
- 'components-tabs__tab-content',
- className
- ) }
+ className={ className }
>
{ children }
From 845178330a2f6d1faf862141186a55b0fa899861 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 22 Sep 2023 14:46:18 -0400
Subject: [PATCH 18/42] remove old scss import
---
packages/components/src/style.scss | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss
index b28eac22d1a7c..02dce83f66dcc 100644
--- a/packages/components/src/style.scss
+++ b/packages/components/src/style.scss
@@ -48,7 +48,6 @@
@import "./select-control/style.scss";
@import "./snackbar/style.scss";
@import "./tab-panel/style.scss";
-@import "./tabs/style.scss";
@import "./text-control/style.scss";
@import "./tip/style.scss";
@import "./toolbar/toolbar/style.scss";
From 800b56f335c85a94ce165a0c80dade428ce1d25a Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 22 Sep 2023 15:19:22 -0400
Subject: [PATCH 19/42] unit test cleanup
---
packages/components/src/tabs/test/index.tsx | 18 ++++++++----------
1 file changed, 8 insertions(+), 10 deletions(-)
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index a44cf45334e94..3731187f9aead 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -819,11 +819,9 @@ describe( 'Tabs', () => {
expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
// Delta (which is disabled) has focus
- await waitFor( () =>
- expect(
- screen.getByRole( 'tab', { name: 'Delta' } )
- ).toHaveFocus()
- );
+ expect(
+ screen.getByRole( 'tab', { name: 'Delta' } )
+ ).toHaveFocus();
// Alpha retains the selection, even if it's not focused.
expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
@@ -859,7 +857,7 @@ describe( 'Tabs', () => {
} );
it( 'should select first enabled tab when the tab associated to `initialTabId` is disabled', async () => {
- const TABS_ONLY_BETA_ENABLED = TABS.map( ( tabObj ) =>
+ const TABS_ONLY_GAMMA_ENABLED = TABS.map( ( tabObj ) =>
tabObj.id !== 'gamma'
? {
...tabObj,
@@ -872,7 +870,7 @@ describe( 'Tabs', () => {
);
const { rerender } = render(
);
@@ -1006,7 +1004,7 @@ describe( 'Tabs', () => {
// No tabpanel should be rendered either
expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument();
} );
- it( 'should not render any tab if `selectedTabId` refers to an disabled tab', async () => {
+ 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'
@@ -1037,7 +1035,7 @@ describe( 'Tabs', () => {
expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument();
} );
describe( 'Without `initialTabId` prop', () => {
- it( 'should render the tab specified by the `specifiedTabId` prop', async () => {
+ it( 'should render the tab specified by the `selectedTabId` prop', async () => {
render( );
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
@@ -1050,7 +1048,7 @@ describe( 'Tabs', () => {
);
- // Remove
+ // Remove beta
rerender(
tab.id !== 'beta' ) }
From 41053ef016c8102e8ae6263b2acb97550f1b1cef Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 22 Sep 2023 17:40:54 -0400
Subject: [PATCH 20/42] remove excess initialTabId + controlled mode tests &
reorganize
---
packages/components/src/tabs/test/index.tsx | 329 +++-----------------
1 file changed, 49 insertions(+), 280 deletions(-)
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index 3731187f9aead..37939d657088a 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -989,6 +989,25 @@ describe( 'Tabs', () => {
} );
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', () => {
render(
{
// No tabpanel should be rendered either
expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument();
} );
- 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
+ it( 'should not render any tab if the active tab is removed', async () => {
+ const { rerender } = render(
+
);
- render(
+ // Remove beta
+ rerender(
tab.id !== 'beta' ) }
/>
);
+ expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 );
+
// 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();
- } );
+ expect(
+ screen.queryByRole( 'tab', { selected: true } )
+ ).not.toBeInTheDocument();
// No tabpanel should be rendered either
expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument();
} );
- describe( 'Without `initialTabId` prop', () => {
- 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 not render any tab if the active tab is removed', async () => {
- const { rerender } = render(
-
- );
-
- // Remove beta
- rerender(
- tab.id !== 'beta' ) }
- />
- );
-
- 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();
- } );
- } );
- describe( 'With `initialTabId`', () => {
- it( 'should render the specified `selectedTabId`, and ignore the `initialTabId` prop', async () => {
- render(
-
+ 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
);
- expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- } );
- it( 'should render the specified `selectedTabId` when `initialTabId` does not match any known tab', async () => {
render(
- );
-
- // No tab should be selected i.e. it doesn't fall back to first tab.
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- } );
- it( 'should not change tabs when initialTabId is changed', async () => {
- const { rerender } = render(
-
- );
-
- rerender(
-
- );
-
- expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
- } );
- it( 'should not render any tab if the currently active tab is removed', async () => {
- const { rerender } = render(
-
- );
-
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
-
- // Remove beta
- rerender(
- tab.id !== 'beta' ) }
- selectedTabId="beta"
- initialTabId="gamma"
- />
- );
-
- 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( '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();
+ await waitFor( () => {
+ expect(
+ screen.queryByRole( 'tab', { selected: true } )
+ ).not.toBeInTheDocument();
+ } );
// No tabpanel should be rendered either
expect(
screen.queryByRole( 'tabpanel' )
).not.toBeInTheDocument();
} );
- it( 'does not select `initialTabId` if it becomes available after the initial render', async () => {
- const { rerender } = render(
-
- );
-
- // The controlled tab, Beta, should be selected.
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
-
- // Re-render with the tab associated to `initialTabId` available.
- rerender(
-
- );
-
- // Beta should remain selected, even after the `initialTabId` of Delta becomes available.
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- } );
- } );
- describe( 'Disabled tab', () => {
- it( 'should render the specified `selectedTabId` (not the first enabled tab) when the tab associated to `initialTabId` is disabled', async () => {
- const TABS_WITH_BETA_DISABLED = TABS.map( ( tabObj ) =>
- tabObj.id === 'beta'
- ? {
- ...tabObj,
- tab: {
- ...tabObj.tab,
- disabled: true,
- },
- }
- : tabObj
- );
-
- const { rerender } = render(
-
- );
-
- 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 not render any tab when the selected tab becomes disabled', async () => {
const { rerender } = render(
@@ -1275,69 +1121,6 @@ describe( 'Tabs', () => {
);
- // 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();
- } );
- it( 'should not render any tab when the tab associated to `initialTabId` becomes disabled while being the active tab', async () => {
- 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
- );
-
- 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(
@@ -1350,18 +1133,4 @@ describe( 'Tabs', () => {
} );
} );
} );
-
- describe( 'Without `initialTabId` prop', () => {
- describe( 'in uncontrolled mode', () => {} );
- describe( 'in controlled mode', () => {} );
- } );
- describe( 'With `initialTabId`', () => {
- describe( 'in uncontrolled mode', () => {} );
- describe( 'in controlled mode', () => {} );
- } );
- describe( 'Controlled Mode', () => {} );
- describe( 'Disabled Tab', () => {
- describe( 'in uncontrolled mode', () => {} );
- describe( 'in controlled mode', () => {} );
- } );
} );
From fb90eb0a728f692868e4e991050b02885a442034 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 22 Sep 2023 18:20:02 -0400
Subject: [PATCH 21/42] add handling for active tab becoming disabled when
`initialTabId` was provided
---
packages/components/src/tabs/index.tsx | 26 +++++++++----
packages/components/src/tabs/test/index.tsx | 43 +++++++++++++++++++++
2 files changed, 62 insertions(+), 7 deletions(-)
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index 50cc21e0c7700..41c6057b53c6e 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -52,6 +52,9 @@ function Tabs( {
// 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( () => {
@@ -59,10 +62,6 @@ function Tabs( {
return;
}
- const initialTab = items.find(
- ( item ) => item.id === `${ instanceId }-${ initialTabId }`
- );
-
// 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
@@ -82,8 +81,8 @@ function Tabs( {
}
}, [
firstEnabledTab?.id,
+ initialTab,
initialTabId,
- instanceId,
isControlled,
items,
selectedId,
@@ -103,12 +102,25 @@ function Tabs( {
return;
}
- // If the currently selected tab becomes disabled, select the first
+ // 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, isControlled, selectedTab?.dimmed, setSelectedId ] );
+ }, [
+ firstEnabledTab,
+ initialTab,
+ isControlled,
+ selectedTab?.dimmed,
+ setSelectedId,
+ ] );
return (
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index 37939d657088a..495759b1b0eac 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -732,6 +732,49 @@ describe( 'Tabs', () => {
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(
From 3af61fe4da27c059d2ce74af5a9db90a7a09de09 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Sat, 23 Sep 2023 10:51:11 -0400
Subject: [PATCH 22/42] add tab disabled/removed stories
---
.../src/tabs/stories/index.story.tsx | 82 +++++++++++++++++++
1 file changed, 82 insertions(+)
diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx
index 559d467d85c82..b27c8bd42edf5 100644
--- a/packages/components/src/tabs/stories/index.story.tsx
+++ b/packages/components/src/tabs/stories/index.story.tsx
@@ -303,3 +303,85 @@ export const ControlledMode = ControlledModeTemplate.bind( {} );
ControlledMode.args = {
selectedTabId: 'tab3',
};
+
+const TabBecomesDisabledTemplate: StoryFn< typeof Tabs > = ( props ) => {
+ const [ disableTab2, setDisableTab2 ] = useState( false );
+
+ return (
+ <>
+ setDisableTab2( ! disableTab2 ) }
+ >
+ { disableTab2 ? 'Enable' : 'Disable' } Tab 2
+
+
+
+
+ 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 (
+ <>
+ setRemoveTab1( ! removeTab1 ) }
+ >
+ { removeTab1 ? 'Restore' : 'Remove' } Tab 1
+
+
+
+ { ! removeTab1 && (
+
+ Tab 1
+
+ ) }
+
+ Tab 2
+
+
+ Tab 3
+
+
+
+ Selected tab: Tab 1
+
+
+ Selected tab: Tab 2
+
+
+ Selected tab: Tab 3
+
+
+ >
+ );
+};
+export const TabGetsRemoved = TabGetsRemovedTemplate.bind( {} );
From 187de6eed0124c6a7d4d5327f85f9d14e323c37d Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Sat, 23 Sep 2023 11:51:00 -0400
Subject: [PATCH 23/42] controlled mode: do not restore selection if active tab
is removed and then restored
---
packages/components/src/tabs/index.tsx | 24 +++++++++++++++++-
packages/components/src/tabs/test/index.tsx | 28 ++++++++++++++++++---
2 files changed, 47 insertions(+), 5 deletions(-)
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index 41c6057b53c6e..1ae914dbf4f9f 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -6,7 +6,7 @@ import * as Ariakit from '@ariakit/react';
/**
* WordPress dependencies
*/
-import { useInstanceId } from '@wordpress/compose';
+import { useInstanceId, usePrevious } from '@wordpress/compose';
import { useEffect, useLayoutEffect } from '@wordpress/element';
/**
@@ -122,6 +122,28 @@ function Tabs( {
setSelectedId,
] );
+ const previousSelectedId = usePrevious( selectedId );
+ // Clear `selectedId` if the active tab is removed from the DOM in controlled mode.
+ useEffect( () => {
+ if ( ! isControlled ) {
+ return;
+ }
+
+ // If there was a previously selected tab (i.e. not 'undefined' as on the
+ // first render), and the `selectedTabId` can't be found, clear the
+ // selection.
+ if ( !! previousSelectedId && !! selectedTabId && ! selectedTab ) {
+ setSelectedId( null );
+ }
+ }, [
+ isControlled,
+ previousSelectedId,
+ selectedId,
+ selectedTab,
+ selectedTabId,
+ setSelectedId,
+ ] );
+
return (
{ children }
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index 495759b1b0eac..d2b68c7db9830 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -1051,7 +1051,7 @@ describe( 'Tabs', () => {
expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' );
} );
- it( 'should not render any tab if `selectedTabId` does not match any known tab', () => {
+ 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.
- expect(
- screen.queryByRole( 'tab', { selected: true } )
- ).not.toBeInTheDocument();
+ // `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();
} );
@@ -1075,12 +1079,28 @@ describe( 'Tabs', () => {
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();
From ce094520806e2f9ec4057a4fbb2c3871aa103f50 Mon Sep 17 00:00:00 2001
From: Chad Chadbourne <13856531+chad1008@users.noreply.github.com>
Date: Thu, 28 Sep 2023 15:33:08 -0400
Subject: [PATCH 24/42] more explicit ternary
Co-authored-by: Marco Ciampini
---
packages/components/src/tabs/tab.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx
index 4ef466d9b61ca..4356917fb8fc6 100644
--- a/packages/components/src/tabs/tab.tsx
+++ b/packages/components/src/tabs/tab.tsx
@@ -46,7 +46,7 @@ function Tab( {
render={
}
From 598230ef8f3ce0f075318f7515efe76babe6b3d4 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Thu, 28 Sep 2023 17:04:59 -0400
Subject: [PATCH 25/42] remove `active-class` prop
---
packages/components/src/tabs/README.md | 9 +----
packages/components/src/tabs/index.tsx | 3 +-
packages/components/src/tabs/styles.ts | 4 +--
packages/components/src/tabs/tab.tsx | 7 ++--
packages/components/src/tabs/test/index.tsx | 37 ++++++++++-----------
packages/components/src/tabs/types.ts | 10 ------
6 files changed, 24 insertions(+), 46 deletions(-)
diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md
index d34122b5c97e8..126b08259cd6e 100644
--- a/packages/components/src/tabs/README.md
+++ b/packages/components/src/tabs/README.md
@@ -96,7 +96,7 @@ const MyControlledTabs = () => (
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.
+- `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
@@ -109,13 +109,6 @@ The children elements, which should be at least a `Tabs.Tablist` component and a
- Required: Yes
-###### `activeClass`: `string`
-
-The class to add to the active tab
-
-- Required: No
-- Default: `is-active`
-
###### `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.
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index 1ae914dbf4f9f..3891f8375392a 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -19,7 +19,6 @@ import TabList from './tablist';
import TabPanel from './tabpanel';
function Tabs( {
- activeClass = 'is-active',
selectOnMove = true,
initialTabId,
orientation = 'horizontal',
@@ -145,7 +144,7 @@ function Tabs( {
] );
return (
-
+
{ children }
);
diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts
index dd68acded4cc6..4d011add737d5 100644
--- a/packages/components/src/tabs/styles.ts
+++ b/packages/components/src/tabs/styles.ts
@@ -28,7 +28,7 @@ export const TabButton = styled( Button )`
border: none;
box-shadow: none;
cursor: pointer;
- padding: 3px ${ space( 4 ) }; // Use padding to offset the is-active border, this benefits Windows High Contrast mode
+ 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;
@@ -58,7 +58,7 @@ export const TabButton = styled( Button )`
}
// Active.
- &.is-active::after {
+ &[aria-selected='true']::after {
height: calc( 1 * var( --wp-admin-border-width-focus ) );
// Windows high contrast mode.
diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx
index 4356917fb8fc6..0406d80bdee90 100644
--- a/packages/components/src/tabs/tab.tsx
+++ b/packages/components/src/tabs/tab.tsx
@@ -2,7 +2,6 @@
* External dependencies
*/
import * as Ariakit from '@ariakit/react';
-import classnames from 'classnames';
/**
* WordPress dependencies
@@ -32,15 +31,13 @@ function Tab( {
warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
return null;
}
- const { store, instanceId, activeClass } = context;
+ const { store, instanceId } = context;
const instancedTabId = `${ instanceId }-${ id }`;
return (
{
);
} );
- it( 'should apply the `activeClass` to the selected tab', async () => {
+ it( 'should apply the `[aria-selected="true"]` to the selected tab', async () => {
const user = userEvent.setup();
- const activeClass = 'my-active-tab';
- render(
-
- );
+ render( );
- // Make sure that only the selected tab has the active class
+ // Make sure that only the selected tab has [aria-selected="true"]
expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
- expect( await getSelectedTab() ).toHaveClass( activeClass );
- screen
- .getAllByRole( 'tab', { selected: false } )
- .forEach( ( unselectedTab ) => {
- expect( unselectedTab ).not.toHaveClass( activeClass );
- } );
+ expect( await getSelectedTab() ).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+ expect(
+ screen.getAllByRole( 'tab', { selected: true } ).length
+ ).toBe( 1 );
// Click the 'Beta' tab
await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) );
- // Make sure that only the selected tab has the active class
+ // Make sure that only the selected tab has [aria-selected="true"]
expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- expect( await getSelectedTab() ).toHaveClass( activeClass );
- screen
- .getAllByRole( 'tab', { selected: false } )
- .forEach( ( unselectedTab ) => {
- expect( unselectedTab ).not.toHaveClass( activeClass );
- } );
+ expect( await getSelectedTab() ).toHaveAttribute(
+ 'aria-selected',
+ 'true'
+ );
+ expect(
+ screen.getAllByRole( 'tab', { selected: true } ).length
+ ).toBe( 1 );
} );
} );
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
index 09941f506db89..a5cd63976eb01 100644
--- a/packages/components/src/tabs/types.ts
+++ b/packages/components/src/tabs/types.ts
@@ -14,10 +14,6 @@ export type TabsContextProps =
* The tabStore object returned by Ariakit's `useTabStore` hook.
*/
store: Ariakit.TabStore;
- /**
- * The class name to add to the active tab.
- */
- activeClass: string;
/**
* The unique id string for this instance of the Tabs component.
*/
@@ -32,12 +28,6 @@ export type TabsProps = {
* components.
*/
children: React.ReactNode;
- /**
- * The class name to add to the active tab.
- *
- * @default 'is-active'
- */
- activeClass?: string;
/**
* When `true`, the tab will be selected when receiving focus (automatic tab
* activation). When `false`, the tab will be selected only when clicked
From 92401344f089dcac535b059f670f9e0d77338a45 Mon Sep 17 00:00:00 2001
From: Chad Chadbourne <13856531+chad1008@users.noreply.github.com>
Date: Thu, 28 Sep 2023 17:07:46 -0400
Subject: [PATCH 26/42] Update packages/components/src/tabs/types.ts
Co-authored-by: Marco Ciampini
---
packages/components/src/tabs/types.ts | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
index a5cd63976eb01..18e5a19d3af7a 100644
--- a/packages/components/src/tabs/types.ts
+++ b/packages/components/src/tabs/types.ts
@@ -126,8 +126,7 @@ export type TabPanelProps = {
*/
children?: React.ReactNode;
/**
- * The id of the TabPanel, which is combined with the `Tabs` instanceId and
- * the suffix '-view'.
+ * A unique identifier for the TabPanel, which is used to generate a unique `id` for the underlying element.
*/
id: string;
/**
From 9615e69d33622740a0c200e798107d4bdfb13318 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 29 Sep 2023 08:51:15 -0400
Subject: [PATCH 27/42] removing superfluos test
---
packages/components/src/tabs/test/index.tsx | 29 ---------------------
1 file changed, 29 deletions(-)
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index cfd2b72d9489a..0686ba9629842 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -281,35 +281,6 @@ describe( 'Tabs', () => {
'gamma-class'
);
} );
-
- it( 'should apply the `[aria-selected="true"]` to the selected tab', async () => {
- const user = userEvent.setup();
-
- render( );
-
- // Make sure that only the selected tab has [aria-selected="true"]
- expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' );
- expect( await getSelectedTab() ).toHaveAttribute(
- 'aria-selected',
- 'true'
- );
- expect(
- screen.getAllByRole( 'tab', { selected: true } ).length
- ).toBe( 1 );
-
- // Click the 'Beta' tab
- await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) );
-
- // Make sure that only the selected tab has [aria-selected="true"]
- expect( await getSelectedTab() ).toHaveTextContent( 'Beta' );
- expect( await getSelectedTab() ).toHaveAttribute(
- 'aria-selected',
- 'true'
- );
- expect(
- screen.getAllByRole( 'tab', { selected: true } ).length
- ).toBe( 1 );
- } );
} );
describe( 'Tab Activation', () => {
From f2c20122baa9e8d58d76df61f4ebd4c33a5c1db6 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 29 Sep 2023 12:42:07 -0400
Subject: [PATCH 28/42] handle fallback when no enabled tabs are available
---
packages/components/src/tabs/index.tsx | 35 +++++++++++++++++---------
1 file changed, 23 insertions(+), 12 deletions(-)
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index 3891f8375392a..c86722cad80ce 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -6,8 +6,8 @@ import * as Ariakit from '@ariakit/react';
/**
* WordPress dependencies
*/
-import { useInstanceId, usePrevious } from '@wordpress/compose';
-import { useEffect, useLayoutEffect } from '@wordpress/element';
+import { useInstanceId } from '@wordpress/compose';
+import { useEffect, useLayoutEffect, useRef } from '@wordpress/element';
/**
* Internal dependencies
@@ -46,6 +46,14 @@ function Tabs( {
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`.
@@ -70,16 +78,22 @@ function Tabs( {
}
// If the currently selected tab is missing (i.e. removed from the DOM),
- // fall back to the initial tab or the first enabled tab.
+ // 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 );
- } else {
- setSelectedId( firstEnabledTab?.id );
+ return;
+ }
+
+ if ( firstEnabledTab ) {
+ setSelectedId( firstEnabledTab.id );
+ } else if ( tabsHavePopulated.current ) {
+ setSelectedId( null );
}
}
}, [
- firstEnabledTab?.id,
+ firstEnabledTab,
initialTab,
initialTabId,
isControlled,
@@ -121,22 +135,19 @@ function Tabs( {
setSelectedId,
] );
- const previousSelectedId = usePrevious( selectedId );
// Clear `selectedId` if the active tab is removed from the DOM in controlled mode.
useEffect( () => {
if ( ! isControlled ) {
return;
}
- // If there was a previously selected tab (i.e. not 'undefined' as on the
- // first render), and the `selectedTabId` can't be found, clear the
- // selection.
- if ( !! previousSelectedId && !! selectedTabId && ! selectedTab ) {
+ // Once the tabs have populated, if the `selectedTabId` still can't be
+ // found, clear the selection.
+ if ( tabsHavePopulated.current && !! selectedTabId && ! selectedTab ) {
setSelectedId( null );
}
}, [
isControlled,
- previousSelectedId,
selectedId,
selectedTab,
selectedTabId,
From 31e6fa3631c7aa1e185c82ff99ed5aa2784100eb Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 29 Sep 2023 14:57:23 -0400
Subject: [PATCH 29/42] go away unneeded optional chaining
---
packages/components/src/tabs/index.tsx | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index c86722cad80ce..39f614b1bd328 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -118,14 +118,13 @@ function Tabs( {
// 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 );
+ setSelectedId( initialTab.id );
return;
}
if ( firstEnabledTab ) {
- setSelectedId( firstEnabledTab?.id );
+ setSelectedId( firstEnabledTab.id );
}
}, [
firstEnabledTab,
From d3145dc839b7702b6c8296f12c9a9fd001b863b8 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 29 Sep 2023 15:21:14 -0400
Subject: [PATCH 30/42] replace includes with proper imports in styles
---
packages/components/src/tabs/styles.ts | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts
index 4d011add737d5..468900356eec5 100644
--- a/packages/components/src/tabs/styles.ts
+++ b/packages/components/src/tabs/styles.ts
@@ -9,6 +9,7 @@ import styled from '@emotion/styled';
import Button from '../button';
import { COLORS } from '../utils';
import { space } from '../ui/utils/space';
+import { reduceMotion } from '../utils/reduce-motion';
export const TabListWrapper = styled.div`
display: flex;
@@ -54,7 +55,7 @@ export const TabButton = styled( Button )`
// Animation
transition: all 0.1s linear;
- @include reduce-motion( 'transition' );
+ ${ reduceMotion( 'transition' ) };
}
// Active.
@@ -82,7 +83,7 @@ export const TabButton = styled( Button )`
// Animation
transition: all 0.1s linear;
- @include reduce-motion( 'transition' );
+ ${ reduceMotion( 'transition' ) };
}
&:focus-visible::before {
From a8b5f8af0d64442e54083dd5c7f96eca9a59495e Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 29 Sep 2023 15:32:17 -0400
Subject: [PATCH 31/42] remove @ts-expect-error
---
packages/components/src/tabs/stories/index.story.tsx | 8 +-------
1 file changed, 1 insertion(+), 7 deletions(-)
diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx
index b27c8bd42edf5..9f79c57b5c480 100644
--- a/packages/components/src/tabs/stories/index.story.tsx
+++ b/packages/components/src/tabs/stories/index.story.tsx
@@ -152,13 +152,7 @@ const UsingSlotFillTemplate: StoryFn< typeof Tabs > = ( props ) => {
other stuff
this is fun!
other stuff
-
+
);
From f1e38def8c9c0ad291ae05cd768b418677df39a4 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 29 Sep 2023 18:17:34 -0400
Subject: [PATCH 32/42] add eslint-disable for ariakit imports
---
packages/components/src/tabs/index.tsx | 1 +
packages/components/src/tabs/tab.tsx | 1 +
packages/components/src/tabs/tablist.tsx | 1 +
packages/components/src/tabs/tabpanel.tsx | 1 +
packages/components/src/tabs/types.ts | 1 +
5 files changed, 5 insertions(+)
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index 39f614b1bd328..671865e2358b3 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+// eslint-disable-next-line no-restricted-imports
import * as Ariakit from '@ariakit/react';
/**
diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx
index 0406d80bdee90..46cca28fcdb6c 100644
--- a/packages/components/src/tabs/tab.tsx
+++ b/packages/components/src/tabs/tab.tsx
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+// eslint-disable-next-line no-restricted-imports
import * as Ariakit from '@ariakit/react';
/**
diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx
index ef7743254240e..e8d1dcfbbb72c 100644
--- a/packages/components/src/tabs/tablist.tsx
+++ b/packages/components/src/tabs/tablist.tsx
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+// eslint-disable-next-line no-restricted-imports
import * as Ariakit from '@ariakit/react';
/**
* WordPress dependencies
diff --git a/packages/components/src/tabs/tabpanel.tsx b/packages/components/src/tabs/tabpanel.tsx
index 9068a55bf02f3..b1550beb07c98 100644
--- a/packages/components/src/tabs/tabpanel.tsx
+++ b/packages/components/src/tabs/tabpanel.tsx
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+// eslint-disable-next-line no-restricted-imports
import * as Ariakit from '@ariakit/react';
/**
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
index 18e5a19d3af7a..685e3c3e6555b 100644
--- a/packages/components/src/tabs/types.ts
+++ b/packages/components/src/tabs/types.ts
@@ -1,6 +1,7 @@
/**
* External dependencies
*/
+// eslint-disable-next-line no-restricted-imports
import type * as Ariakit from '@ariakit/react';
/**
From dfe7384dd19350b3ca96e7c74c124d951fba3590 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Wed, 4 Oct 2023 08:46:06 -0400
Subject: [PATCH 33/42] introduce `render` prop for `Tabs.Tab`
---
packages/components/src/tabs/README.md | 16 +--
.../src/tabs/stories/index.story.tsx | 107 +++++++-----------
packages/components/src/tabs/styles.ts | 10 +-
packages/components/src/tabs/tab.tsx | 30 +----
packages/components/src/tabs/test/index.tsx | 82 --------------
packages/components/src/tabs/types.ts | 9 +-
6 files changed, 62 insertions(+), 192 deletions(-)
diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md
index 126b08259cd6e..ffa747eef9d7f 100644
--- a/packages/components/src/tabs/README.md
+++ b/packages/components/src/tabs/README.md
@@ -177,12 +177,6 @@ The id of the tab, which is prepended with the `Tabs` instance ID.
- Required: Yes
-###### `title`: `string`
-
-The label for the tab.
-
-- Required: Yes
-
###### `style`: `React.CSSProperties`
Custom CSS styles for the tab.
@@ -201,18 +195,18 @@ The class name to apply to the tab.
- Required: No
-###### `icon`: `IconType`
+###### `disabled`: `boolean`
-The icon used for the tab button.
+Determines if the tab button should be disabled.
- Required: No
+- Default: `false`
-###### `disabled`: `boolean`
+###### `render`: `React.ReactNode`
-Determines if the tab button should be disabled.
+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
-- Default: `false`
#### TabPanel
diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx
index 9f79c57b5c480..684a55b6621c2 100644
--- a/packages/components/src/tabs/stories/index.story.tsx
+++ b/packages/components/src/tabs/stories/index.story.tsx
@@ -32,15 +32,9 @@ const Template: StoryFn< typeof Tabs > = ( props ) => {
return (
-
- Tab 1
-
-
- Tab 2
-
-
- Tab 3
-
+ Tab 1
+ Tab 2
+ Tab 3
Selected tab: Tab 1
@@ -61,15 +55,11 @@ const DisabledTabTemplate: StoryFn< typeof Tabs > = ( props ) => {
return (
-
+
Tab 1
-
- Tab 2
-
-
- Tab 3
-
+ Tab 2
+ Tab 3
Selected tab: Tab 1
@@ -89,9 +79,24 @@ const WithTabIconsAndTooltipsTemplate: StoryFn< typeof Tabs > = ( props ) => {
return (
-
-
-
+
+ }
+ />
+
+ }
+ />
+
+ }
+ />
Selected tab: Tab 1
@@ -119,15 +124,9 @@ const UsingSlotFillTemplate: StoryFn< typeof Tabs > = ( props ) => {
-
- Tab 1
-
-
- Tab 2
-
-
- Tab 3
-
+ Tab 1
+ Tab 2
+ Tab 3
@@ -181,15 +180,9 @@ const CloseButtonTemplate: StoryFn< typeof Tabs > = ( props ) => {
} }
>
-
- Tab 1
-
-
- Tab 2
-
-
- Tab 3
-
+ Tab 1
+ Tab 2
+ Tab 3
= ( props ) => {
} }
>
-
- Tab 1
-
+ Tab 1
-
- Tab 2
-
+ Tab 2
-
- Tab 3
-
+ Tab 3
Selected tab: Tab 1
@@ -311,19 +298,11 @@ const TabBecomesDisabledTemplate: StoryFn< typeof Tabs > = ( props ) => {
-
- Tab 1
-
-
+ Tab 1
+
Tab 2
-
- Tab 3
-
+ Tab 3
Selected tab: Tab 1
@@ -353,17 +332,9 @@ const TabGetsRemovedTemplate: StoryFn< typeof Tabs > = ( props ) => {
- { ! removeTab1 && (
-
- Tab 1
-
- ) }
-
- Tab 2
-
-
- Tab 3
-
+ { ! removeTab1 && Tab 1 }
+ Tab 2
+ Tab 3
Selected tab: Tab 1
diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts
index 468900356eec5..cd5bdd0cc93cf 100644
--- a/packages/components/src/tabs/styles.ts
+++ b/packages/components/src/tabs/styles.ts
@@ -2,11 +2,12 @@
* External dependencies
*/
import styled from '@emotion/styled';
+// eslint-disable-next-line no-restricted-imports
+import * as Ariakit from '@ariakit/react';
/**
* Internal dependencies
*/
-import Button from '../button';
import { COLORS } from '../utils';
import { space } from '../ui/utils/space';
import { reduceMotion } from '../utils/reduce-motion';
@@ -20,7 +21,7 @@ export const TabListWrapper = styled.div`
}
`;
-export const TabButton = styled( Button )`
+export const Tab = styled( Ariakit.Tab )`
&& {
position: relative;
border-radius: 0;
@@ -33,6 +34,11 @@ export const TabButton = styled( Button )`
margin-left: 0;
font-weight: 500;
+ &[aria-disabled='true'] {
+ cursor: default;
+ opacity: 0.3;
+ }
+
&:focus:not( :disabled ) {
position: relative;
box-shadow: none;
diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx
index 46cca28fcdb6c..5548b0e990123 100644
--- a/packages/components/src/tabs/tab.tsx
+++ b/packages/components/src/tabs/tab.tsx
@@ -1,9 +1,3 @@
-/**
- * External dependencies
- */
-// eslint-disable-next-line no-restricted-imports
-import * as Ariakit from '@ariakit/react';
-
/**
* WordPress dependencies
*/
@@ -16,17 +10,9 @@ import { useContext } from '@wordpress/element';
import type { TabProps } from './types';
import warning from '@wordpress/warning';
import { TabsContext } from './context';
-import { TabButton } from './styles';
+import { Tab as StyledTab } from './styles';
-function Tab( {
- children,
- id,
- className,
- disabled,
- icon,
- title,
- style,
-}: TabProps ) {
+function Tab( { children, id, className, disabled, render, style }: TabProps ) {
const context = useContext( TabsContext );
if ( ! context ) {
warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
@@ -35,22 +21,16 @@ function Tab( {
const { store, instanceId } = context;
const instancedTabId = `${ instanceId }-${ id }`;
return (
-
- }
+ render={ render }
>
{ children }
-
+
);
}
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index 0686ba9629842..4d97070bc7d37 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -15,7 +15,6 @@ import { useState } from '@wordpress/element';
*/
import Tabs from '..';
import type { TabsProps } from '../types';
-import cleanupTooltip from '../../tooltip/test/utils';
import type { IconType } from '../../icon';
type Tab = {
@@ -75,7 +74,6 @@ const UncontrolledTabs = ( {
{
allTabs[ 0 ].getAttribute( 'id' )
);
} );
-
- it( 'should display a tooltip when hovering tabs provided with an icon', async () => {
- const user = userEvent.setup();
-
- render( );
- const allTabs = screen.getAllByRole( 'tab' );
-
- for ( let i = 0; i < allTabs.length; i++ ) {
- expect(
- screen.queryByText( TABS[ i ].title )
- ).not.toBeInTheDocument();
-
- await user.hover( allTabs[ i ] );
-
- await waitFor( () =>
- expect( screen.getByText( TABS[ i ].title ) ).toBeVisible()
- );
-
- await user.unhover( allTabs[ i ] );
- }
-
- await cleanupTooltip( user );
- } );
-
- it( 'should display a tooltip when moving the selection via the keyboard on tabs provided with an icon', async () => {
- const user = userEvent.setup();
-
- const mockOnSelect = jest.fn();
-
- render(
-
- );
-
- expect( await getSelectedTab() ).not.toHaveTextContent( 'Alpha' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' );
- expect( await getSelectedTab() ).not.toHaveFocus();
-
- // Tab to focus the tablist. Make sure alpha is focused, and that the
- // corresponding tooltip is shown.
- expect( screen.queryByText( 'Alpha' ) ).not.toBeInTheDocument();
- await user.keyboard( '[Tab]' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 1 );
- expect( screen.getByText( 'Alpha' ) ).toBeVisible();
- expect( await getSelectedTab() ).toHaveFocus();
-
- // Move selection with arrow keys. Make sure beta is focused, and that
- // the corresponding tooltip is shown.
- expect( screen.queryByText( 'Beta' ) ).not.toBeInTheDocument();
- await user.keyboard( '[ArrowRight]' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 2 );
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
- expect( screen.getByText( 'Beta' ) ).toBeVisible();
- expect( await getSelectedTab() ).toHaveFocus();
-
- // Move selection with arrow keys. Make sure gamma is focused, and that
- // the corresponding tooltip is shown.
- expect( screen.queryByText( 'Gamma' ) ).not.toBeInTheDocument();
- await user.keyboard( '[ArrowRight]' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 3 );
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' );
- expect( screen.getByText( 'Gamma' ) ).toBeVisible();
- expect( await getSelectedTab() ).toHaveFocus();
-
- // Move selection with arrow keys. Make sure beta is focused, and that
- // the corresponding tooltip is shown.
- expect( screen.queryByText( 'Beta' ) ).not.toBeInTheDocument();
- await user.keyboard( '[ArrowLeft]' );
- expect( mockOnSelect ).toHaveBeenCalledTimes( 4 );
- expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' );
- expect( screen.getByText( 'Beta' ) ).toBeVisible();
- expect( await getSelectedTab() ).toHaveFocus();
-
- await cleanupTooltip( user );
- } );
} );
describe( 'Tab Attributes', () => {
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
index 685e3c3e6555b..db6393b129f10 100644
--- a/packages/components/src/tabs/types.ts
+++ b/packages/components/src/tabs/types.ts
@@ -93,10 +93,6 @@ export type TabProps = {
* The id of the tab, which is prepended with the `Tabs` instanceId.
*/
id: string;
- /**
- * The label for the tab.
- */
- title: string;
/**
* Custom CSS styles for the tab.
*/
@@ -119,6 +115,11 @@ export type TabProps = {
* @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 = {
From 2995957d810849235bfb6fffe9455e1ec9b72440 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Wed, 4 Oct 2023 09:10:11 -0400
Subject: [PATCH 34/42] update path to space utils
---
packages/components/src/tabs/styles.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts
index cd5bdd0cc93cf..091ba608fb6ec 100644
--- a/packages/components/src/tabs/styles.ts
+++ b/packages/components/src/tabs/styles.ts
@@ -9,7 +9,7 @@ import * as Ariakit from '@ariakit/react';
* Internal dependencies
*/
import { COLORS } from '../utils';
-import { space } from '../ui/utils/space';
+import { space } from '../utils/space';
import { reduceMotion } from '../utils/reduce-motion';
export const TabListWrapper = styled.div`
From b12db10673be06b71febc4d65a81872fc006407f Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Wed, 4 Oct 2023 09:35:40 -0400
Subject: [PATCH 35/42] add test for uncontrolled mode removing tab when no
other tabs are enabled
---
packages/components/src/tabs/test/index.tsx | 37 +++++++++++++++++++++
1 file changed, 37 insertions(+)
diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx
index 4d97070bc7d37..1b437966239a0 100644
--- a/packages/components/src/tabs/test/index.tsx
+++ b/packages/components/src/tabs/test/index.tsx
@@ -548,6 +548,43 @@ describe( 'Tabs', () => {
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`', () => {
From 2d37e60c95b10eb88d0e13b71c5f2e9b5f8f2992 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Wed, 4 Oct 2023 15:17:43 -0400
Subject: [PATCH 36/42] add ref forwarding
---
packages/components/src/tabs/index.tsx | 6 ++--
packages/components/src/tabs/tab.tsx | 12 ++++---
packages/components/src/tabs/tablist.tsx | 41 +++++++++++----------
packages/components/src/tabs/tabpanel.tsx | 43 ++++++++++++-----------
4 files changed, 54 insertions(+), 48 deletions(-)
diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx
index 671865e2358b3..54f547ad2f52d 100644
--- a/packages/components/src/tabs/index.tsx
+++ b/packages/components/src/tabs/index.tsx
@@ -15,9 +15,9 @@ import { useEffect, useLayoutEffect, useRef } from '@wordpress/element';
*/
import type { TabsProps } from './types';
import { TabsContext } from './context';
-import Tab from './tab';
-import TabList from './tablist';
-import TabPanel from './tabpanel';
+import { Tab } from './tab';
+import { TabList } from './tablist';
+import { TabPanel } from './tabpanel';
function Tabs( {
selectOnMove = true,
diff --git a/packages/components/src/tabs/tab.tsx b/packages/components/src/tabs/tab.tsx
index 5548b0e990123..75b3df1c1ba01 100644
--- a/packages/components/src/tabs/tab.tsx
+++ b/packages/components/src/tabs/tab.tsx
@@ -2,7 +2,7 @@
* WordPress dependencies
*/
-import { useContext } from '@wordpress/element';
+import { useContext, forwardRef } from '@wordpress/element';
/**
* Internal dependencies
@@ -12,7 +12,10 @@ import warning from '@wordpress/warning';
import { TabsContext } from './context';
import { Tab as StyledTab } from './styles';
-function Tab( { children, id, className, disabled, render, style }: TabProps ) {
+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.' );
@@ -22,6 +25,7 @@ function Tab( { children, id, className, disabled, render, style }: TabProps ) {
const instancedTabId = `${ instanceId }-${ id }`;
return (
);
-}
-
-export default Tab;
+} );
diff --git a/packages/components/src/tabs/tablist.tsx b/packages/components/src/tabs/tablist.tsx
index e8d1dcfbbb72c..02255fefd2082 100644
--- a/packages/components/src/tabs/tablist.tsx
+++ b/packages/components/src/tabs/tablist.tsx
@@ -3,10 +3,12 @@
*/
// 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
@@ -15,23 +17,24 @@ import type { TabListProps } from './types';
import { useTabsContext } from './context';
import { TabListWrapper } from './styles';
-function TabList( { children, className, style }: TabListProps ) {
- const context = useTabsContext();
- if ( ! context ) {
- warning( '`Tabs.TabList` must be wrapped in a `Tabs` component.' );
- return null;
+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 }
+
+ );
}
- const { store } = context;
- return (
- }
- >
- { children }
-
- );
-}
-
-export default TabList;
+);
diff --git a/packages/components/src/tabs/tabpanel.tsx b/packages/components/src/tabs/tabpanel.tsx
index b1550beb07c98..fb62fc9191233 100644
--- a/packages/components/src/tabs/tabpanel.tsx
+++ b/packages/components/src/tabs/tabpanel.tsx
@@ -8,7 +8,7 @@ import * as Ariakit from '@ariakit/react';
* WordPress dependencies
*/
-import { useContext } from '@wordpress/element';
+import { forwardRef, useContext } from '@wordpress/element';
/**
* Internal dependencies
@@ -18,24 +18,25 @@ import type { TabPanelProps } from './types';
import warning from '@wordpress/warning';
import { TabsContext } from './context';
-function TabPanel( { children, id, className, style }: TabPanelProps ) {
- const context = useContext( TabsContext );
- if ( ! context ) {
- warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' );
- return null;
- }
- const { store, instanceId } = context;
-
- return (
-
- { children }
-
- );
-}
+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;
-export default TabPanel;
+ return (
+
+ { children }
+
+ );
+ }
+);
From 6831880da245cf54975baf191bd894f72b12ad70 Mon Sep 17 00:00:00 2001
From: Chad Chadbourne <13856531+chad1008@users.noreply.github.com>
Date: Fri, 6 Oct 2023 11:32:23 -0400
Subject: [PATCH 37/42] typo
Co-authored-by: Marco Ciampini
---
packages/components/src/tabs/types.ts | 1 -
1 file changed, 1 deletion(-)
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
index db6393b129f10..dd6a456f5c768 100644
--- a/packages/components/src/tabs/types.ts
+++ b/packages/components/src/tabs/types.ts
@@ -33,7 +33,6 @@ export type TabsProps = {
* 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
*
From 2fe4332dab57309c77deefcd3929bda3d4fb3432 Mon Sep 17 00:00:00 2001
From: Chad Chadbourne <13856531+chad1008@users.noreply.github.com>
Date: Fri, 6 Oct 2023 11:36:57 -0400
Subject: [PATCH 38/42] update `selectedTabId` description
Co-authored-by: Marco Ciampini
---
packages/components/src/tabs/types.ts | 6 ++++--
1 file changed, 4 insertions(+), 2 deletions(-)
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
index dd6a456f5c768..77ffe7f5811b5 100644
--- a/packages/components/src/tabs/types.ts
+++ b/packages/components/src/tabs/types.ts
@@ -66,8 +66,10 @@ export type TabsProps = {
* The Id of the tab to display. This id is prepended with the `Tabs`
* instanceId internally.
*
- * This prop puts the component into controlled mode. A value of
- * `null` will result in no tab being selected.
+ * 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;
};
From 4025488453714b7ef27debe654a470a7eac7c64e Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 6 Oct 2023 11:45:10 -0400
Subject: [PATCH 39/42] misc feedback updates
---
packages/components/src/tabs/README.md | 5 +++--
packages/components/src/tabs/types.ts | 5 ++---
2 files changed, 5 insertions(+), 5 deletions(-)
diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md
index ffa747eef9d7f..800e0932882dc 100644
--- a/packages/components/src/tabs/README.md
+++ b/packages/components/src/tabs/README.md
@@ -138,10 +138,11 @@ The orientation of the `tablist` (`vertical` or `horizontal`)
- Required: No
- Default: `horizontal`
-###### `selectedTabId`: `string | null | undefined`
+###### `selectedTabId`: `string | null`
The ID of the tab to display. This id is prepended with the `Tabs` instanceId internally.
-This prop puts the component into controlled mode. A value of `null` will result in no tab being selected.
+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
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
index 77ffe7f5811b5..46ecd6eac00c4 100644
--- a/packages/components/src/tabs/types.ts
+++ b/packages/components/src/tabs/types.ts
@@ -53,9 +53,8 @@ export type TabsProps = {
* The function called when a tab has been selected.
* It is passed the `instanceId`-prefixed `tabId` as an argument.
*/
- onSelect?:
- | ( ( selectedId: string | null | undefined ) => void )
- | undefined;
+ onSelect?: ( selectedId: string | null | undefined ) => void;
+
/**
* The orientation of the tablist.
*
From 8367af6bb6311656c5f2fd19c09cc24522fe5516 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 6 Oct 2023 11:48:23 -0400
Subject: [PATCH 40/42] mark as experimental
---
packages/components/src/tabs/README.md | 4 ++++
packages/components/src/tabs/stories/index.story.tsx | 2 +-
2 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md
index 800e0932882dc..6907f385fda37 100644
--- a/packages/components/src/tabs/README.md
+++ b/packages/components/src/tabs/README.md
@@ -1,5 +1,9 @@
# 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.
diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx
index 684a55b6621c2..3b6ba022f6d91 100644
--- a/packages/components/src/tabs/stories/index.story.tsx
+++ b/packages/components/src/tabs/stories/index.story.tsx
@@ -18,7 +18,7 @@ import DropdownMenu from '../../dropdown-menu';
import Button from '../../button';
const meta: Meta< typeof Tabs > = {
- title: 'Components/Tabs',
+ title: 'Components (Experimental)/Tabs',
component: Tabs,
parameters: {
actions: { argTypesRegex: '^on.*' },
From e24d31ac91b8f854dc9c6a05faf7b58475a99621 Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 6 Oct 2023 11:54:05 -0400
Subject: [PATCH 41/42] update `onSelect` JSDoc description
---
packages/components/src/tabs/types.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts
index 46ecd6eac00c4..88e25eb5a3863 100644
--- a/packages/components/src/tabs/types.ts
+++ b/packages/components/src/tabs/types.ts
@@ -51,7 +51,7 @@ export type TabsProps = {
initialTabId?: string;
/**
* The function called when a tab has been selected.
- * It is passed the `instanceId`-prefixed `tabId` as an argument.
+ * It is passed the id of the newly selected tab as an argument.
*/
onSelect?: ( selectedId: string | null | undefined ) => void;
From 8d3ac35341bb3a0aa56c2d605ec83f8b24539d7a Mon Sep 17 00:00:00 2001
From: chad1008 <13856531+chad1008@users.noreply.github.com>
Date: Fri, 6 Oct 2023 11:57:00 -0400
Subject: [PATCH 42/42] update CHANGELOG
---
packages/components/CHANGELOG.md | 4 ++++
1 file changed, 4 insertions(+)
diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md
index 42ba2a8fc9c9d..4c31abc5a27bc 100644
--- a/packages/components/CHANGELOG.md
+++ b/packages/components/CHANGELOG.md
@@ -32,6 +32,10 @@
- `SlotFill`: Migrate to TypeScript and Convert to Functional Component ` `. ([#51350](https://github.com/WordPress/gutenberg/pull/51350)).
- `Components`: move `ui/utils` to `utils` and remove `ui/` folder ([#54922](https://github.com/WordPress/gutenberg/pull/54922)).
+### Experimental
+
+- Introduce `Tabs`, an experimental v2 of `TabPanel`: ([#53960](https://github.com/WordPress/gutenberg/pull/53960)).
+
## 25.8.0 (2023-09-20)
### Enhancements