From f20e9894349e79b68dcb05d216eccc832d6b117f Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 15 Jun 2023 11:36:59 -0400 Subject: [PATCH 01/27] Add Ariakit-powered version of TabPanel --- package-lock.json | 148 ++++++++++++++++++- package.json | 1 + packages/components/src/tabs/index.tsx | 188 +++++++++++++++++++++++++ 3 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 packages/components/src/tabs/index.tsx diff --git a/package-lock.json b/package-lock.json index 12ebe3296be944..311d4c10d34aca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7573,6 +7573,147 @@ "@radix-ui/react-compose-refs": "1.0.0" } }, + "@radix-ui/react-tabs": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", + "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-presence": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-roving-focus": "1.0.4", + "@radix-ui/react-use-controllable-state": "1.0.1" + }, + "dependencies": { + "@radix-ui/primitive": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", + "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-collection": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", + "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-slot": "1.0.2" + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", + "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-context": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", + "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-direction": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", + "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-id": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", + "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-presence": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", + "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-use-layout-effect": "1.0.1" + } + }, + "@radix-ui/react-primitive": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", + "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-slot": "1.0.2" + } + }, + "@radix-ui/react-roving-focus": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", + "integrity": "sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "1.0.1", + "@radix-ui/react-collection": "1.0.3", + "@radix-ui/react-compose-refs": "1.0.1", + "@radix-ui/react-context": "1.0.1", + "@radix-ui/react-direction": "1.0.1", + "@radix-ui/react-id": "1.0.1", + "@radix-ui/react-primitive": "1.0.3", + "@radix-ui/react-use-callback-ref": "1.0.1", + "@radix-ui/react-use-controllable-state": "1.0.1" + } + }, + "@radix-ui/react-slot": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", + "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-compose-refs": "1.0.1" + } + }, + "@radix-ui/react-use-callback-ref": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", + "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + }, + "@radix-ui/react-use-controllable-state": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", + "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/react-use-callback-ref": "1.0.1" + } + }, + "@radix-ui/react-use-layout-effect": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", + "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", + "requires": { + "@babel/runtime": "^7.13.10" + } + } + } + }, "@radix-ui/react-use-callback-ref": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", @@ -17428,7 +17569,12 @@ "@emotion/styled": "^11.6.0", "@emotion/utils": "^1.0.0", "@floating-ui/react-dom": "1.0.0", +<<<<<<< HEAD "@radix-ui/react-dropdown-menu": "2.0.4", +======= + "@radix-ui/react-dropdown-menu": "^2.0.4", + "@radix-ui/react-tabs": "^1.0.4", +>>>>>>> 6ced53d63c (Add Ariakit-powered version of TabPanel) "@use-gesture/react": "^10.2.24", "@wordpress/a11y": "file:packages/a11y", "@wordpress/compose": "file:packages/compose", @@ -29267,7 +29413,7 @@ "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", "dev": true }, "code-point-at": { diff --git a/package.json b/package.json index e91a45d78d55a1..f359647c2eeaa2 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "IS_GUTENBERG_PLUGIN": true }, "dependencies": { + "@radix-ui/react-tabs": "1.0.4", "@types/gradient-parser": "0.1.2", "@wordpress/a11y": "file:packages/a11y", "@wordpress/annotations": "file:packages/annotations", diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx new file mode 100644 index 00000000000000..2fd2002737f628 --- /dev/null +++ b/packages/components/src/tabs/index.tsx @@ -0,0 +1,188 @@ +/** + * External dependencies + */ +import * as Ariakit from '@ariakit/react'; +import cx from 'classnames'; + +/** + * WordPress dependencies + */ +import { useEffect, useLayoutEffect } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Button from '../button'; +import type { TabPanelProps } from '../tab-panel/types'; +import { usePrevious } from '@wordpress/compose'; + +/** + * Tabs is 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 MyTabs = () => ( + * + * { ( tab ) =>

{ tab.title }

} + *
+ * ); + * ``` + */ + +export const Tabs = ( props: TabPanelProps ) => { + const { + tabs, + children, + onSelect, + className, + orientation = 'horizontal', + selectOnMove = true, + initialTabName, + activeClass = 'is-active', + } = props; + + const tabStore = Ariakit.useTabStore( { + setSelectedId: ( newTabValue ) => { + if ( typeof newTabValue === 'undefined' || newTabValue === null ) { + return; + } + + const newTab = tabs.find( ( t ) => t.name === newTabValue ); + if ( newTab?.disabled || newTab === selectedTab ) { + return; + } + + onSelect?.( newTabValue ); + }, + orientation, + selectOnMove, + defaultSelectedId: initialTabName, + } ); + + const selectedTabName = tabStore.useState( 'selectedId' ); + const setTabStoreState = tabStore.setState; + + const selectedTab = tabs.find( ( { name } ) => name === selectedTabName ); + + const previousSelectedTabName = usePrevious( selectedTabName ); + + // Ensure `onSelect` is called when the initial tab is selected. + useEffect( () => { + if ( + previousSelectedTabName !== selectedTabName && + selectedTabName === initialTabName && + !! selectedTabName + ) { + onSelect?.( selectedTabName ); + } + }, [ selectedTabName, initialTabName, onSelect, previousSelectedTabName ] ); + + // Handle selecting the initial tab. + useLayoutEffect( () => { + // If there's a selected tab, don't override it. + if ( selectedTab ) { + return; + } + const initialTab = tabs.find( ( tab ) => tab.name === initialTabName ); + // 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. + if ( initialTabName && ! initialTab ) { + return; + } + if ( initialTab && ! initialTab.disabled ) { + // Select the initial tab if it's not disabled. + setTabStoreState( 'selectedId', initialTab.name ); + } else { + // Fallback to the first enabled tab when the initial tab is + // disabled or it can't be found. + const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); + if ( firstEnabledTab ) { + setTabStoreState( 'selectedId', firstEnabledTab.name ); + } + } + }, [ tabs, selectedTab, initialTabName, setTabStoreState ] ); + + // Handle the currently selected tab becoming disabled. + useEffect( () => { + // This effect only runs when the selected tab is defined and becomes disabled. + if ( ! selectedTab?.disabled ) { + return; + } + const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); + // If the currently selected tab becomes disabled, select the first enabled tab. + // (if there is one). + if ( firstEnabledTab ) { + setTabStoreState( 'selectedId', firstEnabledTab.name ); + } + }, [ tabs, selectedTab?.disabled, setTabStoreState ] ); + + return ( +
+ + { tabs.map( ( tab ) => { + return ( + + } + > + { ! tab.icon && tab.title } + + ); + } ) } + + { tabs.map( ( tab ) => ( + + { children( tab ) } + + ) ) } +
+ ); +}; + +export default Tabs; From 8b002aed52f98857b41b1f6d723f0129d99b0b3a Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 15 Jun 2023 12:13:32 -0400 Subject: [PATCH 02/27] Copy `TabPanel` stories over to `Tabs` --- .../components/src/tabs/stories/index.tsx | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) create mode 100644 packages/components/src/tabs/stories/index.tsx diff --git a/packages/components/src/tabs/stories/index.tsx b/packages/components/src/tabs/stories/index.tsx new file mode 100644 index 00000000000000..9b526d8cc128cb --- /dev/null +++ b/packages/components/src/tabs/stories/index.tsx @@ -0,0 +1,100 @@ +/** + * 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 ) => { + return ; +}; + +export const Default = Template.bind( {} ); +Default.args = { + children: ( tab ) =>

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 Tabs > = ( 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, + }, + ], +}; From 3f9b7e020ba47ef3d1791136fb15b575841b9d0e Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 15 Jun 2023 12:16:16 -0400 Subject: [PATCH 03/27] Add updated versions of `TabPanel` unit tests --- packages/components/src/tabs/test/index.tsx | 958 ++++++++++++++++++++ 1 file changed, 958 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 00000000000000..43b691ae47e5dd --- /dev/null +++ b/packages/components/src/tabs/test/index.tsx @@ -0,0 +1,958 @@ +/** + * 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 TabPanel from '..'; +import Popover from '../../popover'; +import { Provider as SlotFillProvider } from '../../slot-fill'; + +const TABS = [ + { + name: 'alpha', + title: 'Alpha', + className: 'alpha-class', + }, + { + name: 'beta', + title: 'Beta', + className: 'beta-class', + }, + { + name: 'gamma', + title: 'Gamma', + className: 'gamma-class', + }, +]; + +const getSelectedTab = () => screen.getByRole( 'tab', { selected: true } ); + +let originalGetClientRects: () => DOMRectList; + +describe.each( [ + [ 'uncontrolled', TabPanel ], + // The controlled component tests will be added once we certify the + // uncontrolled component's behaviour on trunk. + // [ 'controlled', TabPanel ], +] )( 'TabPanel %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', () => { + test( 'should use the correct aria attributes', () => { + const panelRenderFunction = jest.fn(); + + render( + + ); + + const tabList = screen.getByRole( 'tablist' ); + const allTabs = screen.getAllByRole( 'tab' ); + const selectedTabPanel = screen.getByRole( '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' ) + ); + } ); + + test( 'should display a tooltip when hovering tabs provided with an icon', async () => { + const user = userEvent.setup(); + + const panelRenderFunction = jest.fn(); + + const TABS_WITH_ICON = [ + { ...TABS[ 0 ], icon: wordpress }, + { ...TABS[ 1 ], icon: category }, + { ...TABS[ 2 ], icon: media }, + ]; + + render( + // In order for the tooltip to display properly, there needs to be + // `Popover.Slot` in which the `Popover` renders outside of the + // `TabPanel` component, otherwise the tooltip renders inline. + + + { /* @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_WITH_ICON[ i ].title ) + ).not.toBeInTheDocument(); + + await user.hover( allTabs[ i ] ); + + await waitFor( () => + expect( + screen.getByText( TABS_WITH_ICON[ i ].title ) + ).toBeVisible() + ); + + await user.unhover( allTabs[ i ] ); + } + } ); + + test( '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(); + const panelRenderFunction = jest.fn(); + + const TABS_WITH_ICON = [ + { ...TABS[ 0 ], icon: wordpress }, + { ...TABS[ 1 ], icon: category }, + { ...TABS[ 2 ], icon: media }, + ]; + + render( + // In order for the tooltip to display properly, there needs to be + // `Popover.Slot` in which the `Popover` renders outside of the + // `TabPanel` component, otherwise the tooltip renders inline. + + + { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ } + + + ); + + expect( getSelectedTab() ).not.toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + await expect( 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(); + await expect( 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(); + await expect( 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(); + await expect( 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(); + await expect( getSelectedTab() ).toHaveFocus(); + } ); + } ); + + describe( 'Without `initialTabName`', () => { + it( 'should render first tab', async () => { + const panelRenderFunction = jest.fn(); + + render( + + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + screen.getByRole( '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( + undefined } + onSelect={ mockOnSelect } + /> + ); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + } ); + + describe( 'With `initialTabName`', () => { + it( 'should render the tab set by initialTabName prop', () => { + render( + undefined } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should not select a tab when `initialTabName` does not match any known tab', () => { + render( + undefined } + /> + ); + + // 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 initialTabName is changed', () => { + const { rerender } = render( + undefined } + /> + ); + + rerender( + undefined } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should fall back to the tab associated to `initialTabName` if the currently active tab is removed', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + } ); + + it( 'should have no active tabs when the tab associated to `initialTabName` is removed while being the active tab', () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + } ); + + it( 'waits for the tab with the `initialTabName` to be present in the `tabs` array before selecting it', () => { + const mockOnSelect = jest.fn(); + const { rerender } = render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // There should be no selected tab yet. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Delta' ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'delta' ); + } ); + } ); + + describe( 'Disabled Tab', () => { + it( 'should disable the tab when `disabled` is `true`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + 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', () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + { + if ( tab.name !== 'alpha' ) { + return tab; + } + return { ...tab, disabled: true }; + } ) } + children={ () => undefined } + onSelect={ mockOnSelect } + /> + ); + + // As alpha (first tab) is disabled, + // the first enabled tab should be gamma. + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Re-enable all tabs + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // Even if the initial tab becomes enabled again, the selected tab doesn't + // change. + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should select first enabled tab when the tab associated to `initialTabName` is disabled', () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + { + if ( tab.name === 'gamma' ) { + return tab; + } + return { ...tab, disabled: true }; + } ) } + initialTabName="beta" + children={ () => undefined } + onSelect={ mockOnSelect } + /> + ); + + // As alpha (first tab), and beta (the initial tab), are both + // disabled the first enabled tab should be gamma. + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + // Re-enable all tabs + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // Even if the initial tab becomes enabled again, the selected tab doesn't + // change. + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + + it( 'should select the first enabled tab when the selected tab becomes disabled', () => { + const mockOnSelect = jest.fn(); + const { rerender } = render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + rerender( + { + if ( tab.name === 'alpha' ) { + return { ...tab, disabled: true }; + } + return tab; + } ) } + children={ () => undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); + } ); + + it( 'should select the first enabled tab when the tab associated to `initialTabName` becomes disabled while being the active tab', () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + rerender( + undefined } + onSelect={ mockOnSelect } + /> + ); + + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + } ); + } ); + + describe( 'Tab Activation', () => { + it( 'defaults to automatic tab activation (pointer clicks)', async () => { + const user = userEvent.setup(); + const panelRenderFunction = jest.fn(); + const mockOnSelect = jest.fn(); + + render( + + ); + + // Alpha is the initially selected tab + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + screen.getByRole( '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( 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( 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( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure alpha is focused. + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + await expect( getSelectedTab() ).toHaveFocus(); + + // Navigate forward with arrow keys and make sure the Beta tab is + // selected automatically. + await user.keyboard( '[ArrowRight]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + await expect( 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( 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( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure Alpha is focused. + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + await expect( 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( 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( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure alpha is focused. + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + await expect( getSelectedTab() ).toHaveFocus(); + + // Press the arrow up key, nothing happens. + await user.keyboard( '[ArrowUp]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); + + // Press the arrow down key, nothing happens + await user.keyboard( '[ArrowDown]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( 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( + undefined } + onSelect={ mockOnSelect } + orientation="vertical" + /> + ); + + expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( + 'aria-orientation', + 'vertical' + ); + + // Make sure alpha is still focused. + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).toHaveFocus(); + + // Navigate forward with arrow keys and make sure the Beta tab is + // selected automatically. + await user.keyboard( '[ArrowDown]' ); + expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + await expect( 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( 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( + undefined } + onSelect={ mockOnSelect } + /> + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure Alpha is focused. + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + await expect( 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + } ); + + it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + undefined } + onSelect={ mockOnSelect } + selectOnMove={ false } + /> + ); + + // 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( screen.getByRole( '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", () => { + render( undefined } /> ); + + expect( screen.getByRole( '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( + undefined } + /> + ); + + // Make sure that only the selected tab has the active class + expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( 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( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( getSelectedTab() ).toHaveClass( activeClass ); + screen + .getAllByRole( 'tab', { selected: false } ) + .forEach( ( unselectedTab ) => { + expect( unselectedTab ).not.toHaveClass( activeClass ); + } ); + } ); + } ); +} ); From 97a4edcabf7710ea92ce4c3d5c1f2b4c01a47a91 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Mon, 19 Jun 2023 08:14:19 -0400 Subject: [PATCH 04/27] add "Manual Activation" story --- .../components/src/tab-panel/stories/index.tsx | 16 ++++++++++++++++ packages/components/src/tabs/stories/index.tsx | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/packages/components/src/tab-panel/stories/index.tsx b/packages/components/src/tab-panel/stories/index.tsx index 07c076e1ce621a..2e140e139e653a 100644 --- a/packages/components/src/tab-panel/stories/index.tsx +++ b/packages/components/src/tab-panel/stories/index.tsx @@ -96,3 +96,19 @@ WithTabIconsAndTooltips.args = { }, ], }; + +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/stories/index.tsx b/packages/components/src/tabs/stories/index.tsx index 9b526d8cc128cb..0a351a76e45c00 100644 --- a/packages/components/src/tabs/stories/index.tsx +++ b/packages/components/src/tabs/stories/index.tsx @@ -98,3 +98,19 @@ WithTabIconsAndTooltips.args = { }, ], }; + +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 bbac641276bf85257fe617ab86aa6afd469c8359 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Wed, 28 Jun 2023 13:05:19 -0400 Subject: [PATCH 05/27] switch to async/findby based unit testing --- packages/components/src/tabs/test/index.tsx | 187 ++++++++++---------- 1 file changed, 95 insertions(+), 92 deletions(-) diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx index 43b691ae47e5dd..859ee28f772026 100644 --- a/packages/components/src/tabs/test/index.tsx +++ b/packages/components/src/tabs/test/index.tsx @@ -34,7 +34,8 @@ const TABS = [ }, ]; -const getSelectedTab = () => screen.getByRole( 'tab', { selected: true } ); +const getSelectedTab = async () => + await screen.findByRole( 'tab', { selected: true } ); let originalGetClientRects: () => DOMRectList; @@ -62,7 +63,7 @@ describe.each( [ } ); describe( 'Accessibility and semantics', () => { - test( 'should use the correct aria attributes', () => { + it( 'should use the correct aria attributes', async () => { const panelRenderFunction = jest.fn(); render( @@ -71,7 +72,7 @@ describe.each( [ const tabList = screen.getByRole( 'tablist' ); const allTabs = screen.getAllByRole( 'tab' ); - const selectedTabPanel = screen.getByRole( 'tabpanel' ); + const selectedTabPanel = await screen.findByRole( 'tabpanel' ); expect( tabList ).toBeVisible(); expect( tabList ).toHaveAttribute( @@ -94,7 +95,7 @@ describe.each( [ ); } ); - test( 'should display a tooltip when hovering tabs provided with an icon', async () => { + it( 'should display a tooltip when hovering tabs provided with an icon', async () => { const user = userEvent.setup(); const panelRenderFunction = jest.fn(); @@ -138,7 +139,7 @@ describe.each( [ } } ); - test( 'should display a tooltip when moving the selection via the keyboard on tabs provided with an icon', async () => { + 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(); @@ -165,10 +166,10 @@ describe.each( [ ); - expect( getSelectedTab() ).not.toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + await expect( await getSelectedTab() ).not.toHaveFocus(); // Tab to focus the tablist. Make sure alpha is focused, and that the // corresponding tooltip is shown. @@ -176,7 +177,7 @@ describe.each( [ await user.keyboard( '[Tab]' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( screen.getByText( 'Alpha' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure beta is focused, and that // the corresponding tooltip is shown. @@ -185,7 +186,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure gamma is focused, and that // the corresponding tooltip is shown. @@ -194,7 +195,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); expect( screen.getByText( 'Gamma' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure beta is focused, and that // the corresponding tooltip is shown. @@ -203,7 +204,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); } ); } ); @@ -215,9 +216,9 @@ describe.each( [ ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( - screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) ).toBeInTheDocument(); } ); @@ -238,12 +239,12 @@ describe.each( [ onSelect={ mockOnSelect } /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); } ); describe( 'With `initialTabName`', () => { - it( 'should render the tab set by initialTabName prop', () => { + it( 'should render the tab set by initialTabName prop', async () => { render( ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); it( 'should not select a tab when `initialTabName` does not match any known tab', () => { @@ -272,7 +273,7 @@ describe.each( [ // No tabpanel should be rendered either expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); } ); - it( 'should not change tabs when initialTabName is changed', () => { + it( 'should not change tabs when initialTabName is changed', async () => { const { rerender } = render( ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); it( 'should fall back to the tab associated to `initialTabName` if the currently active tab is removed', async () => { @@ -305,12 +306,12 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -323,12 +324,12 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); } ); - it( 'should have no active tabs when the tab associated to `initialTabName` is removed while being the active tab', () => { + it( 'should have no active tabs when the tab associated to `initialTabName` is removed while being the active tab', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -340,7 +341,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); @@ -360,7 +361,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); } ); - it( 'waits for the tab with the `initialTabName` to be present in the `tabs` array before selecting it', () => { + it( 'waits for the tab with the `initialTabName` to be present in the `tabs` array before selecting it', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( ); - expect( getSelectedTab() ).toHaveTextContent( 'Delta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Delta' ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'delta' ); } ); } ); @@ -431,7 +432,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); } ); - it( 'should select first enabled tab when the initial tab is disabled', () => { + it( 'should select first enabled tab when the initial tab is disabled', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -450,7 +451,7 @@ describe.each( [ // As alpha (first tab) is disabled, // the first enabled tab should be gamma. - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); // Re-enable all tabs rerender( @@ -463,10 +464,10 @@ describe.each( [ // Even if the initial tab becomes enabled again, the selected tab doesn't // change. - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); - it( 'should select first enabled tab when the tab associated to `initialTabName` is disabled', () => { + it( 'should select first enabled tab when the tab associated to `initialTabName` is disabled', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -485,7 +486,7 @@ describe.each( [ // As alpha (first tab), and beta (the initial tab), are both // disabled the first enabled tab should be gamma. - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); // Re-enable all tabs rerender( @@ -499,10 +500,10 @@ describe.each( [ // Even if the initial tab becomes enabled again, the selected tab doesn't // change. - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); } ); - it( 'should select the first enabled tab when the selected tab becomes disabled', () => { + it( 'should select the first enabled tab when the selected tab becomes disabled', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -529,7 +530,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); @@ -541,12 +542,12 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + 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 `initialTabName` becomes disabled while being the active tab', () => { + it( 'should select the first enabled tab when the tab associated to `initialTabName` becomes disabled while being the active tab', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -558,7 +559,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); @@ -575,7 +576,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -588,7 +589,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); } ); } ); @@ -608,16 +609,16 @@ describe.each( [ ); // Alpha is the initially selected tab - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( - screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + 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( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( screen.getByRole( 'tabpanel', { name: 'Beta' } ) ).toBeInTheDocument(); @@ -626,7 +627,7 @@ describe.each( [ // Click on Alpha, make sure beta is the selected tab await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( screen.getByRole( 'tabpanel', { name: 'Alpha' } ) ).toBeInTheDocument(); @@ -649,24 +650,24 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); // Navigate forward with arrow keys and make sure the Beta tab is // selected automatically. await user.keyboard( '[ArrowRight]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -687,24 +688,24 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure Alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + await 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -725,22 +726,22 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); // Press the arrow up key, nothing happens. await user.keyboard( '[ArrowUp]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Press the arrow down key, nothing happens await user.keyboard( '[ArrowDown]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -761,38 +762,38 @@ describe.each( [ ); // Make sure alpha is still focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); // Navigate forward with arrow keys and make sure the Beta tab is // selected automatically. await user.keyboard( '[ArrowDown]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -821,10 +822,10 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure Alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Press the right arrow key three times. Since the delta tab is disabled: @@ -833,7 +834,7 @@ describe.each( [ // `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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); await expect( screen.getByRole( 'tab', { name: 'Delta' } ) ).toHaveFocus(); @@ -844,16 +845,16 @@ describe.each( [ // The `mockOnSelect` callback doesn't fire, since the gamma tab was // already selected. await user.keyboard( '[ArrowLeft]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); } ); @@ -885,7 +886,9 @@ describe.each( [ // or enter key. await user.keyboard( '[ArrowRight]' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveFocus(); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).toHaveFocus(); await user.keyboard( '[Enter]' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); @@ -907,12 +910,12 @@ describe.each( [ } ); describe( 'Tab Attributes', () => { - it( "should apply the tab's `className` to the tab button", () => { + it( "should apply the tab's `className` to the tab button", async () => { render( undefined } /> ); - expect( screen.getByRole( 'tab', { name: 'Alpha' } ) ).toHaveClass( - 'alpha-class' - ); + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveClass( 'alpha-class' ); expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveClass( 'beta-class' ); @@ -934,8 +937,8 @@ describe.each( [ ); // Make sure that only the selected tab has the active class - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( getSelectedTab() ).toHaveClass( activeClass ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveClass( activeClass ); screen .getAllByRole( 'tab', { selected: false } ) .forEach( ( unselectedTab ) => { @@ -946,8 +949,8 @@ describe.each( [ await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); // Make sure that only the selected tab has the active class - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( getSelectedTab() ).toHaveClass( activeClass ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveClass( activeClass ); screen .getAllByRole( 'tab', { selected: false } ) .forEach( ( unselectedTab ) => { From 379547766fd8eb0005ea7ee1173818174965268d Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 29 Jun 2023 12:56:45 -0400 Subject: [PATCH 06/27] 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 45ed6d2ec13fc8..dc6254912e767e 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -44,6 +44,10 @@ - `ItemGroup`: Update button focus state styles to be inline with other button focus states in the editor. ([#51576](https://github.com/WordPress/gutenberg/pull/51576)). - `ItemGroup`: Update button focus state styles to target `:focus-visible` rather than `:focus`. ([#51787](https://github.com/WordPress/gutenberg/pull/51787)). +### Experimental + +- `Tabs`: Create a new version of `TabPanel` with updated internals, while maintaining the same functionality and API surface ([#52133](https://github.com/WordPress/gutenberg/pull/52133)). + ### Bug Fix - `Popover`: Allow legitimate 0 positions to update popover position ([#51320](https://github.com/WordPress/gutenberg/pull/51320)). From 5ede3dc97932bce3bdec1a831854dcd72efe3c59 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Wed, 5 Jul 2023 10:50:29 -0400 Subject: [PATCH 07/27] remove old radix dependency --- package-lock.json | 146 ---------------------------------------------- package.json | 1 - 2 files changed, 147 deletions(-) diff --git a/package-lock.json b/package-lock.json index 311d4c10d34aca..34aa841cea3d5c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7573,147 +7573,6 @@ "@radix-ui/react-compose-refs": "1.0.0" } }, - "@radix-ui/react-tabs": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.0.4.tgz", - "integrity": "sha512-egZfYY/+wRNCflXNHx+dePvnz9FbmssDTJBtgRfDY7e8SE5oIo3Py2eCB1ckAbh1Q7cQ/6yJZThJ++sgbxibog==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-direction": "1.0.1", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-presence": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-roving-focus": "1.0.4", - "@radix-ui/react-use-controllable-state": "1.0.1" - }, - "dependencies": { - "@radix-ui/primitive": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.0.1.tgz", - "integrity": "sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-collection": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.0.3.tgz", - "integrity": "sha512-3SzW+0PW7yBBoQlT8wNcGtaxaD0XSu0uLUFgrtHY08Acx05TaHaOmVLR73c0j/cqpDy53KBMO7s0dx2wmOIDIA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-slot": "1.0.2" - } - }, - "@radix-ui/react-compose-refs": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.0.1.tgz", - "integrity": "sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-context": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.0.1.tgz", - "integrity": "sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-direction": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.0.1.tgz", - "integrity": "sha512-RXcvnXgyvYvBEOhCBuddKecVkoMiI10Jcm5cTI7abJRAHYfFxeu+FBQs/DvdxSYucxR5mna0dNsL6QFlds5TMA==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-id": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.0.1.tgz", - "integrity": "sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-layout-effect": "1.0.1" - } - }, - "@radix-ui/react-presence": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.0.1.tgz", - "integrity": "sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-use-layout-effect": "1.0.1" - } - }, - "@radix-ui/react-primitive": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-1.0.3.tgz", - "integrity": "sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-slot": "1.0.2" - } - }, - "@radix-ui/react-roving-focus": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.0.4.tgz", - "integrity": "sha512-2mUg5Mgcu001VkGy+FfzZyzbmuUWzgWkj3rvv4yu+mLw03+mTzbxZHvfcGyFp2b8EkQeMkpRQ5FiA2Vr2O6TeQ==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/primitive": "1.0.1", - "@radix-ui/react-collection": "1.0.3", - "@radix-ui/react-compose-refs": "1.0.1", - "@radix-ui/react-context": "1.0.1", - "@radix-ui/react-direction": "1.0.1", - "@radix-ui/react-id": "1.0.1", - "@radix-ui/react-primitive": "1.0.3", - "@radix-ui/react-use-callback-ref": "1.0.1", - "@radix-ui/react-use-controllable-state": "1.0.1" - } - }, - "@radix-ui/react-slot": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.0.2.tgz", - "integrity": "sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-compose-refs": "1.0.1" - } - }, - "@radix-ui/react-use-callback-ref": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.1.tgz", - "integrity": "sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==", - "requires": { - "@babel/runtime": "^7.13.10" - } - }, - "@radix-ui/react-use-controllable-state": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.0.1.tgz", - "integrity": "sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==", - "requires": { - "@babel/runtime": "^7.13.10", - "@radix-ui/react-use-callback-ref": "1.0.1" - } - }, - "@radix-ui/react-use-layout-effect": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.0.1.tgz", - "integrity": "sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==", - "requires": { - "@babel/runtime": "^7.13.10" - } - } - } - }, "@radix-ui/react-use-callback-ref": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.0.0.tgz", @@ -17569,12 +17428,7 @@ "@emotion/styled": "^11.6.0", "@emotion/utils": "^1.0.0", "@floating-ui/react-dom": "1.0.0", -<<<<<<< HEAD "@radix-ui/react-dropdown-menu": "2.0.4", -======= - "@radix-ui/react-dropdown-menu": "^2.0.4", - "@radix-ui/react-tabs": "^1.0.4", ->>>>>>> 6ced53d63c (Add Ariakit-powered version of TabPanel) "@use-gesture/react": "^10.2.24", "@wordpress/a11y": "file:packages/a11y", "@wordpress/compose": "file:packages/compose", diff --git a/package.json b/package.json index f359647c2eeaa2..e91a45d78d55a1 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,6 @@ "IS_GUTENBERG_PLUGIN": true }, "dependencies": { - "@radix-ui/react-tabs": "1.0.4", "@types/gradient-parser": "0.1.2", "@wordpress/a11y": "file:packages/a11y", "@wordpress/annotations": "file:packages/annotations", From 1bab116faf4ecac05a22f9b1e1b5d122489620bc Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Wed, 5 Jul 2023 11:25:01 -0400 Subject: [PATCH 08/27] rename monolithic component to `TabPanel` for consistency --- packages/components/src/tabs/index.tsx | 4 ++-- packages/components/src/tabs/stories/index.tsx | 16 ++++++++-------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx index 2fd2002737f628..4dcc7a0c551631 100644 --- a/packages/components/src/tabs/index.tsx +++ b/packages/components/src/tabs/index.tsx @@ -53,7 +53,7 @@ import { usePrevious } from '@wordpress/compose'; * ``` */ -export const Tabs = ( props: TabPanelProps ) => { +export const TabPanel = ( props: TabPanelProps ) => { const { tabs, children, @@ -185,4 +185,4 @@ export const Tabs = ( props: TabPanelProps ) => { ); }; -export default Tabs; +export default TabPanel; diff --git a/packages/components/src/tabs/stories/index.tsx b/packages/components/src/tabs/stories/index.tsx index 0a351a76e45c00..ce7b590b73493e 100644 --- a/packages/components/src/tabs/stories/index.tsx +++ b/packages/components/src/tabs/stories/index.tsx @@ -11,13 +11,13 @@ import { wordpress, more, link } from '@wordpress/icons'; /** * Internal dependencies */ -import Tabs from '..'; +import TabPanel from '..'; import Popover from '../../popover'; import { Provider as SlotFillProvider } from '../../slot-fill'; -const meta: ComponentMeta< typeof Tabs > = { - title: 'Components/Tabs', - component: Tabs, +const meta: ComponentMeta< typeof TabPanel > = { + title: 'Components/TabPanel v2', + component: TabPanel, parameters: { actions: { argTypesRegex: '^on.*' }, controls: { expanded: true }, @@ -26,8 +26,8 @@ const meta: ComponentMeta< typeof Tabs > = { }; export default meta; -const Template: ComponentStory< typeof Tabs > = ( props ) => { - return ; +const Template: ComponentStory< typeof TabPanel > = ( props ) => { + return ; }; export const Default = Template.bind( {} ); @@ -67,10 +67,10 @@ DisabledTab.args = { // 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 Tabs > = ( props ) => { +const SlotFillTemplate: ComponentStory< typeof TabPanel > = ( props ) => { return ( - + { /* @ts-expect-error The 'Slot' component hasn't been typed yet. */ } From 75f090c5520c4248b401dac0c2814b8c2a673fa0 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 6 Jul 2023 12:03:35 -0400 Subject: [PATCH 09/27] add `useInstanceId` support --- packages/components/src/tabs/index.tsx | 78 +++++++++++++++++++++----- 1 file changed, 65 insertions(+), 13 deletions(-) diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx index 4dcc7a0c551631..1f1e8d2f95a7f9 100644 --- a/packages/components/src/tabs/index.tsx +++ b/packages/components/src/tabs/index.tsx @@ -7,14 +7,14 @@ import cx from 'classnames'; /** * WordPress dependencies */ -import { useEffect, useLayoutEffect } from '@wordpress/element'; +import { useCallback, useEffect, useLayoutEffect } from '@wordpress/element'; /** * Internal dependencies */ import Button from '../button'; import type { TabPanelProps } from '../tab-panel/types'; -import { usePrevious } from '@wordpress/compose'; +import { useInstanceId, usePrevious } from '@wordpress/compose'; /** * Tabs is an ARIA-compliant tabpanel. @@ -65,25 +65,55 @@ export const TabPanel = ( props: TabPanelProps ) => { activeClass = 'is-active', } = props; + const instanceId = useInstanceId( TabPanel, 'tab-panel' ); + + const prependInstanceId = useCallback( + ( tabName: string | undefined ) => { + if ( typeof tabName === 'undefined' ) { + return; + } + return `${ instanceId }-${ tabName }`; + }, + [ instanceId ] + ); + + // Separate the actual tab name from the instance ID. This is + // necessary because Ariakit internally uses the element ID when + // a new tab is selected, but our implementation looks specifically + // for the tab name to be passed to the `onSelect` callback. + const extractTabName = useCallback( ( id: string | undefined | null ) => { + if ( typeof id === 'undefined' || id === null ) { + return; + } + return id.match( /^tab-panel-[0-9]*-(.*)/ )?.[ 1 ]; + }, [] ); + const tabStore = Ariakit.useTabStore( { setSelectedId: ( newTabValue ) => { if ( typeof newTabValue === 'undefined' || newTabValue === null ) { return; } - const newTab = tabs.find( ( t ) => t.name === newTabValue ); + const newTab = tabs.find( + ( t ) => prependInstanceId( t.name ) === newTabValue + ); if ( newTab?.disabled || newTab === selectedTab ) { return; } - onSelect?.( newTabValue ); + const simplifiedTabName = extractTabName( newTabValue ); + if ( typeof simplifiedTabName === 'undefined' ) { + return; + } + + onSelect?.( simplifiedTabName ); }, orientation, selectOnMove, - defaultSelectedId: initialTabName, + defaultSelectedId: prependInstanceId( initialTabName ), } ); - const selectedTabName = tabStore.useState( 'selectedId' ); + const selectedTabName = extractTabName( tabStore.useState( 'selectedId' ) ); const setTabStoreState = tabStore.setState; const selectedTab = tabs.find( ( { name } ) => name === selectedTabName ); @@ -116,16 +146,29 @@ export const TabPanel = ( props: TabPanelProps ) => { } if ( initialTab && ! initialTab.disabled ) { // Select the initial tab if it's not disabled. - setTabStoreState( 'selectedId', initialTab.name ); + setTabStoreState( + 'selectedId', + prependInstanceId( initialTab.name ) + ); } else { // Fallback to the first enabled tab when the initial tab is // disabled or it can't be found. const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); if ( firstEnabledTab ) { - setTabStoreState( 'selectedId', firstEnabledTab.name ); + setTabStoreState( + 'selectedId', + prependInstanceId( firstEnabledTab.name ) + ); } } - }, [ tabs, selectedTab, initialTabName, setTabStoreState ] ); + }, [ + tabs, + selectedTab, + initialTabName, + instanceId, + setTabStoreState, + prependInstanceId, + ] ); // Handle the currently selected tab becoming disabled. useEffect( () => { @@ -137,9 +180,18 @@ export const TabPanel = ( props: TabPanelProps ) => { // If the currently selected tab becomes disabled, select the first enabled tab. // (if there is one). if ( firstEnabledTab ) { - setTabStoreState( 'selectedId', firstEnabledTab.name ); + setTabStoreState( + 'selectedId', + prependInstanceId( firstEnabledTab.name ) + ); } - }, [ tabs, selectedTab?.disabled, setTabStoreState ] ); + }, [ + tabs, + selectedTab?.disabled, + setTabStoreState, + instanceId, + prependInstanceId, + ] ); return (
@@ -148,7 +200,7 @@ export const TabPanel = ( props: TabPanelProps ) => { return ( { { children( tab ) } From ea1e21eadd21624273f610dc4b180e7720626303 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 6 Jul 2023 12:55:12 -0400 Subject: [PATCH 10/27] add className and id parity with existing component --- packages/components/src/tabs/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx index 1f1e8d2f95a7f9..f3647a7cbcd418 100644 --- a/packages/components/src/tabs/index.tsx +++ b/packages/components/src/tabs/index.tsx @@ -195,7 +195,10 @@ export const TabPanel = ( props: TabPanelProps ) => { return (
- + { tabs.map( ( tab ) => { return ( { { tabs.map( ( tab ) => ( Date: Thu, 6 Jul 2023 14:06:02 -0400 Subject: [PATCH 11/27] update setSelectedId helper --- packages/components/src/tabs/index.tsx | 34 +++++++++----------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx index f3647a7cbcd418..3613c07200a948 100644 --- a/packages/components/src/tabs/index.tsx +++ b/packages/components/src/tabs/index.tsx @@ -114,7 +114,13 @@ export const TabPanel = ( props: TabPanelProps ) => { } ); const selectedTabName = extractTabName( tabStore.useState( 'selectedId' ) ); - const setTabStoreState = tabStore.setState; + + const setTabStoreSelectedId = useCallback( + ( tabName: string ) => { + tabStore.setState( 'selectedId', prependInstanceId( tabName ) ); + }, + [ prependInstanceId, tabStore ] + ); const selectedTab = tabs.find( ( { name } ) => name === selectedTabName ); @@ -146,19 +152,13 @@ export const TabPanel = ( props: TabPanelProps ) => { } if ( initialTab && ! initialTab.disabled ) { // Select the initial tab if it's not disabled. - setTabStoreState( - 'selectedId', - prependInstanceId( initialTab.name ) - ); + setTabStoreSelectedId( initialTab.name ); } else { // Fallback to the first enabled tab when the initial tab is // disabled or it can't be found. const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); if ( firstEnabledTab ) { - setTabStoreState( - 'selectedId', - prependInstanceId( firstEnabledTab.name ) - ); + setTabStoreSelectedId( firstEnabledTab.name ); } } }, [ @@ -166,8 +166,7 @@ export const TabPanel = ( props: TabPanelProps ) => { selectedTab, initialTabName, instanceId, - setTabStoreState, - prependInstanceId, + setTabStoreSelectedId, ] ); // Handle the currently selected tab becoming disabled. @@ -180,18 +179,9 @@ export const TabPanel = ( props: TabPanelProps ) => { // If the currently selected tab becomes disabled, select the first enabled tab. // (if there is one). if ( firstEnabledTab ) { - setTabStoreState( - 'selectedId', - prependInstanceId( firstEnabledTab.name ) - ); + setTabStoreSelectedId( firstEnabledTab.name ); } - }, [ - tabs, - selectedTab?.disabled, - setTabStoreState, - instanceId, - prependInstanceId, - ] ); + }, [ tabs, selectedTab?.disabled, setTabStoreSelectedId, instanceId ] ); return (
From e7b0aa20a522faf6431a833e08463bdc77c199e9 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 6 Jul 2023 14:39:44 -0400 Subject: [PATCH 12/27] apply Ariakit internals to existing TabPanel component --- packages/components/src/tab-panel/index.tsx | 212 ++++++++++++-------- packages/components/src/tab-panel/types.ts | 11 +- 2 files changed, 125 insertions(+), 98 deletions(-) diff --git a/packages/components/src/tab-panel/index.tsx b/packages/components/src/tab-panel/index.tsx index 20063b2315dd39..d6a95da1e751f5 100644 --- a/packages/components/src/tab-panel/index.tsx +++ b/packages/components/src/tab-panel/index.tsx @@ -1,7 +1,8 @@ /** * External dependencies */ -import classnames from 'classnames'; +import * as Ariakit from '@ariakit/react'; +import cx from 'classnames'; import type { ForwardedRef } from 'react'; /** @@ -9,39 +10,20 @@ import type { ForwardedRef } from 'react'; */ import { forwardRef, - useState, useEffect, useLayoutEffect, useCallback, } from '@wordpress/element'; -import { useInstanceId } from '@wordpress/compose'; +import { useInstanceId, usePrevious } from '@wordpress/compose'; /** * Internal dependencies */ -import { NavigableMenu } from '../navigable-container'; + import Button from '../button'; -import type { TabButtonProps, TabPanelProps } from './types'; +import type { TabPanelProps } from './types'; import type { WordPressComponentProps } from '../ui/context'; -const TabButton = ( { - tabId, - children, - selected, - ...rest -}: TabButtonProps ) => ( - -); - /** * TabPanel is an ARIA-compliant tabpanel. * @@ -92,26 +74,76 @@ const UnforwardedTabPanel = ( ref: ForwardedRef< any > ) => { const instanceId = useInstanceId( TabPanel, 'tab-panel' ); - const [ selected, setSelected ] = useState< string >(); - const handleTabSelection = useCallback( - ( tabKey: string ) => { - setSelected( tabKey ); - onSelect?.( tabKey ); + const prependInstanceId = useCallback( + ( tabName: string | undefined ) => { + if ( typeof tabName === 'undefined' ) { + return; + } + return `${ instanceId }-${ tabName }`; }, - [ onSelect ] + [ instanceId ] ); - // Simulate a click on the newly focused tab, which causes the component - // to show the `tab-panel` associated with the clicked tab. - const activateTabAutomatically = ( - _childIndex: number, - child: HTMLElement - ) => { - child.click(); - }; - const selectedTab = tabs.find( ( { name } ) => name === selected ); - const selectedId = `${ instanceId }-${ selectedTab?.name ?? 'none' }`; + // Separate the actual tab name from the instance ID. This is + // necessary because Ariakit internally uses the element ID when + // a new tab is selected, but our implementation looks specifically + // for the tab name to be passed to the `onSelect` callback. + const extractTabName = useCallback( ( id: string | undefined | null ) => { + if ( typeof id === 'undefined' || id === null ) { + return; + } + return id.match( /^tab-panel-[0-9]*-(.*)/ )?.[ 1 ]; + }, [] ); + + const tabStore = Ariakit.useTabStore( { + setSelectedId: ( newTabValue ) => { + if ( typeof newTabValue === 'undefined' || newTabValue === null ) { + return; + } + + const newTab = tabs.find( + ( t ) => prependInstanceId( t.name ) === newTabValue + ); + if ( newTab?.disabled || newTab === selectedTab ) { + return; + } + + const simplifiedTabName = extractTabName( newTabValue ); + if ( typeof simplifiedTabName === 'undefined' ) { + return; + } + + onSelect?.( simplifiedTabName ); + }, + orientation, + selectOnMove, + defaultSelectedId: prependInstanceId( initialTabName ), + } ); + + const selectedTabName = extractTabName( tabStore.useState( 'selectedId' ) ); + + const setTabStoreSelectedId = useCallback( + ( tabName: string ) => { + tabStore.setState( 'selectedId', prependInstanceId( tabName ) ); + }, + [ prependInstanceId, tabStore ] + ); + + const selectedTab = tabs.find( ( { name } ) => name === selectedTabName ); + + const previousSelectedTabName = usePrevious( selectedTabName ); + + // Ensure `onSelect` is called when the initial tab is selected. + useEffect( () => { + if ( + previousSelectedTabName !== selectedTabName && + selectedTabName === initialTabName && + !! selectedTabName + ) { + onSelect?.( selectedTabName ); + } + }, [ selectedTabName, initialTabName, onSelect, previousSelectedTabName ] ); // Handle selecting the initial tab. useLayoutEffect( () => { @@ -119,25 +151,31 @@ const UnforwardedTabPanel = ( if ( selectedTab ) { return; } - const initialTab = tabs.find( ( tab ) => tab.name === initialTabName ); - // 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. if ( initialTabName && ! initialTab ) { return; } - if ( initialTab && ! initialTab.disabled ) { // Select the initial tab if it's not disabled. - handleTabSelection( initialTab.name ); + setTabStoreSelectedId( initialTab.name ); } else { - // Fallback to the first enabled tab when the initial is disabled. + // Fallback to the first enabled tab when the initial tab is + // disabled or it can't be found. const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); - if ( firstEnabledTab ) handleTabSelection( firstEnabledTab.name ); + if ( firstEnabledTab ) { + setTabStoreSelectedId( firstEnabledTab.name ); + } } - }, [ tabs, selectedTab, initialTabName, handleTabSelection ] ); + }, [ + tabs, + selectedTab, + initialTabName, + instanceId, + setTabStoreSelectedId, + ] ); // Handle the currently selected tab becoming disabled. useEffect( () => { @@ -145,60 +183,58 @@ const UnforwardedTabPanel = ( if ( ! selectedTab?.disabled ) { return; } - const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); - // If the currently selected tab becomes disabled, select the first enabled tab. // (if there is one). if ( firstEnabledTab ) { - handleTabSelection( firstEnabledTab.name ); + setTabStoreSelectedId( firstEnabledTab.name ); } - }, [ tabs, selectedTab?.disabled, handleTabSelection ] ); + }, [ tabs, selectedTab?.disabled, setTabStoreSelectedId, instanceId ] ); return (
- - { tabs.map( ( tab ) => ( - { + return ( + } - ) } - tabId={ `${ instanceId }-${ tab.name }` } - aria-controls={ `${ instanceId }-${ tab.name }-view` } - selected={ tab.name === selected } - key={ tab.name } - onClick={ () => handleTabSelection( tab.name ) } - disabled={ tab.disabled } - label={ tab.icon && tab.title } - icon={ tab.icon } - showTooltip={ !! tab.icon } - > - { ! tab.icon && tab.title } - - ) ) } - - { selectedTab && ( -
+ { ! tab.icon && tab.title } + + ); + } ) } + + { tabs.map( ( tab ) => ( + - { children( selectedTab ) } -
- ) } + { children( tab ) } + + ) ) }
); }; diff --git a/packages/components/src/tab-panel/types.ts b/packages/components/src/tab-panel/types.ts index 4bef866923ebca..1f4dc7c677483a 100644 --- a/packages/components/src/tab-panel/types.ts +++ b/packages/components/src/tab-panel/types.ts @@ -1,7 +1,7 @@ /** * External dependencies */ -import type { MouseEvent, ReactNode } from 'react'; +import type { ReactNode } from 'react'; /** * Internal dependencies @@ -31,15 +31,6 @@ type Tab = { disabled?: boolean; } & Record< any, any >; -export type TabButtonProps = { - children: ReactNode; - label?: string; - onClick: ( event: MouseEvent ) => void; - selected: boolean; - showTooltip?: boolean; - tabId: string; -} & Pick< Tab, 'className' | 'icon' | 'disabled' >; - export type TabPanelProps = { /** * The class name to add to the active tab. From 6195305e481f02b4b2af0777e0eb3cbba61b27ae Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 6 Jul 2023 14:51:53 -0400 Subject: [PATCH 13/27] update TabPanel unit tests to work with Ariakit internals --- .../components/src/tab-panel/test/index.tsx | 214 +++++++++--------- 1 file changed, 108 insertions(+), 106 deletions(-) diff --git a/packages/components/src/tab-panel/test/index.tsx b/packages/components/src/tab-panel/test/index.tsx index 16f88ee8a41e98..859ee28f772026 100644 --- a/packages/components/src/tab-panel/test/index.tsx +++ b/packages/components/src/tab-panel/test/index.tsx @@ -34,7 +34,8 @@ const TABS = [ }, ]; -const getSelectedTab = () => screen.getByRole( 'tab', { selected: true } ); +const getSelectedTab = async () => + await screen.findByRole( 'tab', { selected: true } ); let originalGetClientRects: () => DOMRectList; @@ -62,7 +63,7 @@ describe.each( [ } ); describe( 'Accessibility and semantics', () => { - test( 'should use the correct aria attributes', () => { + it( 'should use the correct aria attributes', async () => { const panelRenderFunction = jest.fn(); render( @@ -71,7 +72,7 @@ describe.each( [ const tabList = screen.getByRole( 'tablist' ); const allTabs = screen.getAllByRole( 'tab' ); - const selectedTabPanel = screen.getByRole( 'tabpanel' ); + const selectedTabPanel = await screen.findByRole( 'tabpanel' ); expect( tabList ).toBeVisible(); expect( tabList ).toHaveAttribute( @@ -94,7 +95,7 @@ describe.each( [ ); } ); - test( 'should display a tooltip when hovering tabs provided with an icon', async () => { + it( 'should display a tooltip when hovering tabs provided with an icon', async () => { const user = userEvent.setup(); const panelRenderFunction = jest.fn(); @@ -138,7 +139,7 @@ describe.each( [ } } ); - test( 'should display a tooltip when moving the selection via the keyboard on tabs provided with an icon', async () => { + 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(); @@ -165,10 +166,10 @@ describe.each( [ ); - expect( getSelectedTab() ).not.toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + await expect( await getSelectedTab() ).not.toHaveFocus(); // Tab to focus the tablist. Make sure alpha is focused, and that the // corresponding tooltip is shown. @@ -176,7 +177,7 @@ describe.each( [ await user.keyboard( '[Tab]' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( screen.getByText( 'Alpha' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure beta is focused, and that // the corresponding tooltip is shown. @@ -185,7 +186,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure gamma is focused, and that // the corresponding tooltip is shown. @@ -194,7 +195,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); expect( screen.getByText( 'Gamma' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure beta is focused, and that // the corresponding tooltip is shown. @@ -203,7 +204,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); } ); } ); @@ -215,11 +216,10 @@ describe.each( [ ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( - screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); } ); it( 'should fall back to first enabled tab if the active tab is removed', async () => { @@ -239,12 +239,12 @@ describe.each( [ onSelect={ mockOnSelect } /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); } ); describe( 'With `initialTabName`', () => { - it( 'should render the tab set by initialTabName prop', () => { + it( 'should render the tab set by initialTabName prop', async () => { render( ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); it( 'should not select a tab when `initialTabName` does not match any known tab', () => { @@ -273,8 +273,7 @@ describe.each( [ // No tabpanel should be rendered either expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); } ); - - it( 'should not change tabs when initialTabName is changed', () => { + it( 'should not change tabs when initialTabName is changed', async () => { const { rerender } = render( ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); it( 'should fall back to the tab associated to `initialTabName` if the currently active tab is removed', async () => { @@ -307,12 +306,12 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -325,12 +324,12 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); } ); - it( 'should have no active tabs when the tab associated to `initialTabName` is removed while being the active tab', () => { + it( 'should have no active tabs when the tab associated to `initialTabName` is removed while being the active tab', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -342,7 +341,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); @@ -362,7 +361,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); } ); - it( 'waits for the tab with the `initialTabName` to be present in the `tabs` array before selecting it', () => { + it( 'waits for the tab with the `initialTabName` to be present in the `tabs` array before selecting it', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( ); - expect( getSelectedTab() ).toHaveTextContent( 'Delta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Delta' ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'delta' ); } ); } ); @@ -433,7 +432,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); } ); - it( 'should select first enabled tab when the initial tab is disabled', () => { + it( 'should select first enabled tab when the initial tab is disabled', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -452,7 +451,7 @@ describe.each( [ // As alpha (first tab) is disabled, // the first enabled tab should be gamma. - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); // Re-enable all tabs rerender( @@ -465,10 +464,10 @@ describe.each( [ // Even if the initial tab becomes enabled again, the selected tab doesn't // change. - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); } ); - it( 'should select first enabled tab when the tab associated to `initialTabName` is disabled', () => { + it( 'should select first enabled tab when the tab associated to `initialTabName` is disabled', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -487,7 +486,7 @@ describe.each( [ // As alpha (first tab), and beta (the initial tab), are both // disabled the first enabled tab should be gamma. - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); // Re-enable all tabs rerender( @@ -501,10 +500,10 @@ describe.each( [ // Even if the initial tab becomes enabled again, the selected tab doesn't // change. - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); } ); - it( 'should select the first enabled tab when the selected tab becomes disabled', () => { + it( 'should select the first enabled tab when the selected tab becomes disabled', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -531,7 +530,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); @@ -543,12 +542,12 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + 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 `initialTabName` becomes disabled while being the active tab', () => { + it( 'should select the first enabled tab when the tab associated to `initialTabName` becomes disabled while being the active tab', async () => { const mockOnSelect = jest.fn(); const { rerender } = render( @@ -560,7 +559,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); @@ -577,7 +576,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -590,7 +589,7 @@ describe.each( [ /> ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); } ); } ); @@ -610,31 +609,28 @@ describe.each( [ ); // Alpha is the initially selected tab - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( - screen.getByRole( 'tabpanel', { name: 'Alpha' } ) + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Click on Beta, make sure beta is the selected tab await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); expect( screen.getByRole( 'tabpanel', { name: 'Beta' } ) ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 1 ] ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); // Click on Alpha, make sure beta is the selected tab await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); expect( screen.getByRole( 'tabpanel', { name: 'Alpha' } ) ).toBeInTheDocument(); - expect( panelRenderFunction ).toHaveBeenLastCalledWith( TABS[ 0 ] ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -654,24 +650,24 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); // Navigate forward with arrow keys and make sure the Beta tab is // selected automatically. await user.keyboard( '[ArrowRight]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -692,24 +688,24 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure Alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + await 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -730,22 +726,22 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); // Press the arrow up key, nothing happens. await user.keyboard( '[ArrowUp]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); // Press the arrow down key, nothing happens await user.keyboard( '[ArrowDown]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -766,38 +762,38 @@ describe.each( [ ); // Make sure alpha is still focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); // Navigate forward with arrow keys and make sure the Beta tab is // selected automatically. await user.keyboard( '[ArrowDown]' ); - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await 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( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -826,10 +822,10 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Tab to focus the tablist. Make sure Alpha is focused. - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + await expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( getSelectedTab() ).toHaveFocus(); + await expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Press the right arrow key three times. Since the delta tab is disabled: @@ -838,7 +834,7 @@ describe.each( [ // `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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); await expect( screen.getByRole( 'tab', { name: 'Delta' } ) ).toHaveFocus(); @@ -846,18 +842,20 @@ describe.each( [ 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); - // Click on on the disabled tab. Compared to using arrow keys to move the + // 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( getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + await expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); } ); it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { @@ -873,47 +871,51 @@ describe.each( [ /> ); - // onSelect gets called on the initial 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( 2 ); + 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( 2 ); - expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).toHaveFocus(); await user.keyboard( '[Enter]' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + 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( 3 ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveFocus(); await user.keyboard( '[Space]' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); } ); } ); describe( 'Tab Attributes', () => { - it( "should apply the tab's `className` to the tab button", () => { + it( "should apply the tab's `className` to the tab button", async () => { render( undefined } /> ); - expect( screen.getByRole( 'tab', { name: 'Alpha' } ) ).toHaveClass( - 'alpha-class' - ); + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveClass( 'alpha-class' ); expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveClass( 'beta-class' ); @@ -935,8 +937,8 @@ describe.each( [ ); // Make sure that only the selected tab has the active class - expect( getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( getSelectedTab() ).toHaveClass( activeClass ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveClass( activeClass ); screen .getAllByRole( 'tab', { selected: false } ) .forEach( ( unselectedTab ) => { @@ -947,8 +949,8 @@ describe.each( [ await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); // Make sure that only the selected tab has the active class - expect( getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( getSelectedTab() ).toHaveClass( activeClass ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveClass( activeClass ); screen .getAllByRole( 'tab', { selected: false } ) .forEach( ( unselectedTab ) => { From 68fcf7e6e0cf179cf32aae58e2e55e16ae5b3fc2 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Wed, 12 Jul 2023 13:17:45 -0400 Subject: [PATCH 14/27] remove temporary separate/new component files --- packages/components/src/tabs/index.tsx | 234 ----- .../components/src/tabs/stories/index.tsx | 116 --- packages/components/src/tabs/test/index.tsx | 961 ------------------ 3 files changed, 1311 deletions(-) delete mode 100644 packages/components/src/tabs/index.tsx delete mode 100644 packages/components/src/tabs/stories/index.tsx delete mode 100644 packages/components/src/tabs/test/index.tsx diff --git a/packages/components/src/tabs/index.tsx b/packages/components/src/tabs/index.tsx deleted file mode 100644 index 3613c07200a948..00000000000000 --- a/packages/components/src/tabs/index.tsx +++ /dev/null @@ -1,234 +0,0 @@ -/** - * External dependencies - */ -import * as Ariakit from '@ariakit/react'; -import cx from 'classnames'; - -/** - * WordPress dependencies - */ -import { useCallback, useEffect, useLayoutEffect } from '@wordpress/element'; - -/** - * Internal dependencies - */ -import Button from '../button'; -import type { TabPanelProps } from '../tab-panel/types'; -import { useInstanceId, usePrevious } from '@wordpress/compose'; - -/** - * Tabs is 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 MyTabs = () => ( - * - * { ( tab ) =>

{ tab.title }

} - *
- * ); - * ``` - */ - -export const TabPanel = ( props: TabPanelProps ) => { - const { - tabs, - children, - onSelect, - className, - orientation = 'horizontal', - selectOnMove = true, - initialTabName, - activeClass = 'is-active', - } = props; - - const instanceId = useInstanceId( TabPanel, 'tab-panel' ); - - const prependInstanceId = useCallback( - ( tabName: string | undefined ) => { - if ( typeof tabName === 'undefined' ) { - return; - } - return `${ instanceId }-${ tabName }`; - }, - [ instanceId ] - ); - - // Separate the actual tab name from the instance ID. This is - // necessary because Ariakit internally uses the element ID when - // a new tab is selected, but our implementation looks specifically - // for the tab name to be passed to the `onSelect` callback. - const extractTabName = useCallback( ( id: string | undefined | null ) => { - if ( typeof id === 'undefined' || id === null ) { - return; - } - return id.match( /^tab-panel-[0-9]*-(.*)/ )?.[ 1 ]; - }, [] ); - - const tabStore = Ariakit.useTabStore( { - setSelectedId: ( newTabValue ) => { - if ( typeof newTabValue === 'undefined' || newTabValue === null ) { - return; - } - - const newTab = tabs.find( - ( t ) => prependInstanceId( t.name ) === newTabValue - ); - if ( newTab?.disabled || newTab === selectedTab ) { - return; - } - - const simplifiedTabName = extractTabName( newTabValue ); - if ( typeof simplifiedTabName === 'undefined' ) { - return; - } - - onSelect?.( simplifiedTabName ); - }, - orientation, - selectOnMove, - defaultSelectedId: prependInstanceId( initialTabName ), - } ); - - const selectedTabName = extractTabName( tabStore.useState( 'selectedId' ) ); - - const setTabStoreSelectedId = useCallback( - ( tabName: string ) => { - tabStore.setState( 'selectedId', prependInstanceId( tabName ) ); - }, - [ prependInstanceId, tabStore ] - ); - - const selectedTab = tabs.find( ( { name } ) => name === selectedTabName ); - - const previousSelectedTabName = usePrevious( selectedTabName ); - - // Ensure `onSelect` is called when the initial tab is selected. - useEffect( () => { - if ( - previousSelectedTabName !== selectedTabName && - selectedTabName === initialTabName && - !! selectedTabName - ) { - onSelect?.( selectedTabName ); - } - }, [ selectedTabName, initialTabName, onSelect, previousSelectedTabName ] ); - - // Handle selecting the initial tab. - useLayoutEffect( () => { - // If there's a selected tab, don't override it. - if ( selectedTab ) { - return; - } - const initialTab = tabs.find( ( tab ) => tab.name === initialTabName ); - // 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. - if ( initialTabName && ! initialTab ) { - return; - } - if ( initialTab && ! initialTab.disabled ) { - // Select the initial tab if it's not disabled. - setTabStoreSelectedId( initialTab.name ); - } else { - // Fallback to the first enabled tab when the initial tab is - // disabled or it can't be found. - const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); - if ( firstEnabledTab ) { - setTabStoreSelectedId( firstEnabledTab.name ); - } - } - }, [ - tabs, - selectedTab, - initialTabName, - instanceId, - setTabStoreSelectedId, - ] ); - - // Handle the currently selected tab becoming disabled. - useEffect( () => { - // This effect only runs when the selected tab is defined and becomes disabled. - if ( ! selectedTab?.disabled ) { - return; - } - const firstEnabledTab = tabs.find( ( tab ) => ! tab.disabled ); - // If the currently selected tab becomes disabled, select the first enabled tab. - // (if there is one). - if ( firstEnabledTab ) { - setTabStoreSelectedId( firstEnabledTab.name ); - } - }, [ tabs, selectedTab?.disabled, setTabStoreSelectedId, instanceId ] ); - - return ( -
- - { tabs.map( ( tab ) => { - return ( - - } - > - { ! tab.icon && tab.title } - - ); - } ) } - - { tabs.map( ( tab ) => ( - - { children( tab ) } - - ) ) } -
- ); -}; - -export default TabPanel; diff --git a/packages/components/src/tabs/stories/index.tsx b/packages/components/src/tabs/stories/index.tsx deleted file mode 100644 index ce7b590b73493e..00000000000000 --- a/packages/components/src/tabs/stories/index.tsx +++ /dev/null @@ -1,116 +0,0 @@ -/** - * External dependencies - */ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; - -/** - * WordPress dependencies - */ -import { wordpress, more, link } from '@wordpress/icons'; - -/** - * Internal dependencies - */ -import TabPanel from '..'; -import Popover from '../../popover'; -import { Provider as SlotFillProvider } from '../../slot-fill'; - -const meta: ComponentMeta< typeof TabPanel > = { - title: 'Components/TabPanel v2', - component: TabPanel, - parameters: { - actions: { argTypesRegex: '^on.*' }, - controls: { expanded: true }, - docs: { source: { state: 'open' } }, - }, -}; -export default meta; - -const Template: ComponentStory< typeof TabPanel > = ( props ) => { - return ; -}; - -export const Default = Template.bind( {} ); -Default.args = { - children: ( tab ) =>

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 TabPanel > = ( 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/test/index.tsx b/packages/components/src/tabs/test/index.tsx deleted file mode 100644 index 859ee28f772026..00000000000000 --- a/packages/components/src/tabs/test/index.tsx +++ /dev/null @@ -1,961 +0,0 @@ -/** - * 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 TabPanel from '..'; -import Popover from '../../popover'; -import { Provider as SlotFillProvider } from '../../slot-fill'; - -const TABS = [ - { - name: 'alpha', - title: 'Alpha', - className: 'alpha-class', - }, - { - name: 'beta', - title: 'Beta', - className: 'beta-class', - }, - { - name: 'gamma', - title: 'Gamma', - className: 'gamma-class', - }, -]; - -const getSelectedTab = async () => - await screen.findByRole( 'tab', { selected: true } ); - -let originalGetClientRects: () => DOMRectList; - -describe.each( [ - [ 'uncontrolled', TabPanel ], - // The controlled component tests will be added once we certify the - // uncontrolled component's behaviour on trunk. - // [ 'controlled', TabPanel ], -] )( 'TabPanel %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 () => { - const panelRenderFunction = jest.fn(); - - 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(); - - const panelRenderFunction = jest.fn(); - - const TABS_WITH_ICON = [ - { ...TABS[ 0 ], icon: wordpress }, - { ...TABS[ 1 ], icon: category }, - { ...TABS[ 2 ], icon: media }, - ]; - - render( - // In order for the tooltip to display properly, there needs to be - // `Popover.Slot` in which the `Popover` renders outside of the - // `TabPanel` component, otherwise the tooltip renders inline. - - - { /* @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_WITH_ICON[ i ].title ) - ).not.toBeInTheDocument(); - - await user.hover( allTabs[ i ] ); - - await waitFor( () => - expect( - screen.getByText( TABS_WITH_ICON[ 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(); - const panelRenderFunction = jest.fn(); - - const TABS_WITH_ICON = [ - { ...TABS[ 0 ], icon: wordpress }, - { ...TABS[ 1 ], icon: category }, - { ...TABS[ 2 ], icon: media }, - ]; - - render( - // In order for the tooltip to display properly, there needs to be - // `Popover.Slot` in which the `Popover` renders outside of the - // `TabPanel` component, otherwise the tooltip renders inline. - - - { /* @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' ); - await 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(); - await 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(); - await 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(); - await 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(); - await expect( await getSelectedTab() ).toHaveFocus(); - } ); - } ); - - describe( 'Without `initialTabName`', () => { - it( 'should render first tab', async () => { - const panelRenderFunction = jest.fn(); - - 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( - undefined } - onSelect={ mockOnSelect } - /> - ); - - rerender( - undefined } - onSelect={ mockOnSelect } - /> - ); - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - } ); - } ); - - describe( 'With `initialTabName`', () => { - it( 'should render the tab set by initialTabName prop', async () => { - render( - undefined } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - } ); - - it( 'should not select a tab when `initialTabName` does not match any known tab', () => { - render( - undefined } - /> - ); - - // 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 initialTabName is changed', async () => { - const { rerender } = render( - undefined } - /> - ); - - rerender( - undefined } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - } ); - - it( 'should fall back to the tab associated to `initialTabName` if the currently active tab is removed', async () => { - const user = userEvent.setup(); - const mockOnSelect = jest.fn(); - - const { rerender } = render( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); - - await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - - rerender( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); - } ); - - it( 'should have no active tabs when the tab associated to `initialTabName` is removed while being the active tab', async () => { - const mockOnSelect = jest.fn(); - - const { rerender } = render( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); - - rerender( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - } ); - - it( 'waits for the tab with the `initialTabName` to be present in the `tabs` array before selecting it', async () => { - const mockOnSelect = jest.fn(); - const { rerender } = render( - undefined } - onSelect={ mockOnSelect } - /> - ); - - // There should be no selected tab yet. - expect( - screen.queryByRole( 'tab', { selected: true } ) - ).not.toBeInTheDocument(); - - rerender( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Delta' ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'delta' ); - } ); - } ); - - describe( 'Disabled Tab', () => { - it( 'should disable the tab when `disabled` is `true`', async () => { - const user = userEvent.setup(); - const mockOnSelect = jest.fn(); - - render( - undefined } - onSelect={ mockOnSelect } - /> - ); - - 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( - { - if ( tab.name !== 'alpha' ) { - return tab; - } - return { ...tab, disabled: true }; - } ) } - children={ () => undefined } - onSelect={ mockOnSelect } - /> - ); - - // As alpha (first tab) is disabled, - // the first enabled tab should be gamma. - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - - // Re-enable all tabs - rerender( - undefined } - onSelect={ mockOnSelect } - /> - ); - - // 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 `initialTabName` is disabled', async () => { - const mockOnSelect = jest.fn(); - - const { rerender } = render( - { - if ( tab.name === 'gamma' ) { - return tab; - } - return { ...tab, disabled: true }; - } ) } - initialTabName="beta" - children={ () => undefined } - onSelect={ mockOnSelect } - /> - ); - - // 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( - undefined } - onSelect={ mockOnSelect } - /> - ); - - // 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( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - - rerender( - { - if ( tab.name === 'alpha' ) { - return { ...tab, disabled: true }; - } - return tab; - } ) } - children={ () => undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); - - rerender( - undefined } - onSelect={ mockOnSelect } - /> - ); - - 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 `initialTabName` becomes disabled while being the active tab', async () => { - const mockOnSelect = jest.fn(); - - const { rerender } = render( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); - - rerender( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); - expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - - rerender( - undefined } - onSelect={ mockOnSelect } - /> - ); - - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); - } ); - } ); - - describe( 'Tab Activation', () => { - it( 'defaults to automatic tab activation (pointer clicks)', async () => { - const user = userEvent.setup(); - const panelRenderFunction = jest.fn(); - 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( - undefined } - onSelect={ mockOnSelect } - /> - ); - - // 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' ); - await expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); - await 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' ); - await 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' ); - await 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( - undefined } - onSelect={ mockOnSelect } - /> - ); - - // 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' ); - await expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); - await 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' ); - await 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' ); - await 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( - undefined } - onSelect={ mockOnSelect } - /> - ); - - // 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' ); - await expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); - await expect( await getSelectedTab() ).toHaveFocus(); - - // Press the arrow up key, nothing happens. - await user.keyboard( '[ArrowUp]' ); - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await 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' ); - await 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( - undefined } - onSelect={ mockOnSelect } - orientation="vertical" - /> - ); - - expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( - 'aria-orientation', - 'vertical' - ); - - // Make sure alpha is still focused. - expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await 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' ); - await 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' ); - await 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' ); - await 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' ); - await 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( - undefined } - onSelect={ mockOnSelect } - /> - ); - - // 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' ); - await expect( await getSelectedTab() ).not.toHaveFocus(); - await user.keyboard( '[Tab]' ); - await 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' ); - await 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' ); - await 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' ); - await expect( await getSelectedTab() ).toHaveFocus(); - expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); - } ); - - it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { - const user = userEvent.setup(); - const mockOnSelect = jest.fn(); - - render( - undefined } - onSelect={ mockOnSelect } - selectOnMove={ false } - /> - ); - - // 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( undefined } /> ); - - 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( - undefined } - /> - ); - - // 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 b9e019c40c5e85029759e0eb65a88334b0976094 Mon Sep 17 00:00:00 2001 From: Chad Chadbourne <13856531+chad1008@users.noreply.github.com> Date: Thu, 13 Jul 2023 15:35:54 -0400 Subject: [PATCH 15/27] Update packages/components/src/tab-panel/stories/index.tsx Co-authored-by: Lena Morita --- packages/components/src/tab-panel/stories/index.tsx | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/packages/components/src/tab-panel/stories/index.tsx b/packages/components/src/tab-panel/stories/index.tsx index 2e140e139e653a..b6a247d99ebdfb 100644 --- a/packages/components/src/tab-panel/stories/index.tsx +++ b/packages/components/src/tab-panel/stories/index.tsx @@ -99,16 +99,6 @@ WithTabIconsAndTooltips.args = { export const ManualActivation = Template.bind( {} ); ManualActivation.args = { - children: ( tab ) =>

Selected tab: { tab.title }

, - tabs: [ - { - name: 'tab1', - title: 'Tab 1', - }, - { - name: 'tab2', - title: 'Tab 2', - }, - ], + ...Default.args, selectOnMove: false, }; From 37c014be2fd6fa4d737743f89b63b8e71b566c73 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 13 Jul 2023 16:24:39 -0400 Subject: [PATCH 16/27] update CHANGELOG entry --- packages/components/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index dc6254912e767e..593ca9c6400faf 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -16,6 +16,10 @@ - `Toolbar`: Fix toolbar items not being tabbable on the first render. ([#52613](https://github.com/WordPress/gutenberg/pull/52613)) - `FormTokenField`: Fix token overflow when moving cursor left or right. ([#52662](https://github.com/WordPress/gutenberg/pull/52662)) +### Internal + +- `TabPanel`: Introduce a new version of `TabPanel` with updated internals while maintaining the same functionality and API surface ([#52133](https://github.com/WordPress/gutenberg/pull/52133)). + ## 25.3.0 (2023-07-05) ### Enhancements @@ -44,10 +48,6 @@ - `ItemGroup`: Update button focus state styles to be inline with other button focus states in the editor. ([#51576](https://github.com/WordPress/gutenberg/pull/51576)). - `ItemGroup`: Update button focus state styles to target `:focus-visible` rather than `:focus`. ([#51787](https://github.com/WordPress/gutenberg/pull/51787)). -### Experimental - -- `Tabs`: Create a new version of `TabPanel` with updated internals, while maintaining the same functionality and API surface ([#52133](https://github.com/WordPress/gutenberg/pull/52133)). - ### Bug Fix - `Popover`: Allow legitimate 0 positions to update popover position ([#51320](https://github.com/WordPress/gutenberg/pull/51320)). From c89aedd1d1edbbad3dea3de598ad6ac8b63e2553 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 13 Jul 2023 16:39:48 -0400 Subject: [PATCH 17/27] restore package-lock.json --- package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 34aa841cea3d5c..12ebe3296be944 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29267,7 +29267,7 @@ "co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", - "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true }, "code-point-at": { From 582afa58cd3cb9cbdb9d56866d470fa6d634e3f1 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 13 Jul 2023 16:44:25 -0400 Subject: [PATCH 18/27] remove unnecessary `await`s --- .../components/src/tab-panel/test/index.tsx | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/components/src/tab-panel/test/index.tsx b/packages/components/src/tab-panel/test/index.tsx index 859ee28f772026..25ef03e9db4aa0 100644 --- a/packages/components/src/tab-panel/test/index.tsx +++ b/packages/components/src/tab-panel/test/index.tsx @@ -169,7 +169,7 @@ describe.each( [ expect( await getSelectedTab() ).not.toHaveTextContent( 'Alpha' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); - await expect( await getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).not.toHaveFocus(); // Tab to focus the tablist. Make sure alpha is focused, and that the // corresponding tooltip is shown. @@ -177,7 +177,7 @@ describe.each( [ await user.keyboard( '[Tab]' ); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( screen.getByText( 'Alpha' ) ).toBeInTheDocument(); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure beta is focused, and that // the corresponding tooltip is shown. @@ -186,7 +186,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure gamma is focused, and that // the corresponding tooltip is shown. @@ -195,7 +195,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); expect( screen.getByText( 'Gamma' ) ).toBeInTheDocument(); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); // Move selection with arrow keys. Make sure beta is focused, and that // the corresponding tooltip is shown. @@ -204,7 +204,7 @@ describe.each( [ expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); } ); } ); @@ -651,15 +651,15 @@ describe.each( [ // Tab to focus the tablist. Make sure alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( await getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( await getSelectedTab() ).toHaveFocus(); + 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' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); @@ -667,7 +667,7 @@ describe.each( [ // selected automatically. await user.keyboard( '[ArrowLeft]' ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -689,15 +689,15 @@ describe.each( [ // Tab to focus the tablist. Make sure Alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( await getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( await getSelectedTab() ).toHaveFocus(); + 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' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); @@ -705,7 +705,7 @@ describe.each( [ // selected automatically. await user.keyboard( '[ArrowRight]' ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -727,21 +727,21 @@ describe.each( [ // Tab to focus the tablist. Make sure alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( await getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); // Press the arrow up key, nothing happens. await user.keyboard( '[ArrowUp]' ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( await getSelectedTab() ).toHaveFocus(); + 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' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -763,13 +763,13 @@ describe.each( [ // Make sure alpha is still focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( await getSelectedTab() ).toHaveFocus(); + 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' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'beta' ); @@ -777,7 +777,7 @@ describe.each( [ // selected automatically. await user.keyboard( '[ArrowUp]' ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); @@ -785,7 +785,7 @@ describe.each( [ // selected automatically. await user.keyboard( '[ArrowUp]' ); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'gamma' ); @@ -793,7 +793,7 @@ describe.each( [ // selected automatically. await user.keyboard( '[ArrowDown]' ); expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); expect( mockOnSelect ).toHaveBeenLastCalledWith( 'alpha' ); } ); @@ -823,9 +823,9 @@ describe.each( [ // Tab to focus the tablist. Make sure Alpha is focused. expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); - await expect( await getSelectedTab() ).not.toHaveFocus(); + expect( await getSelectedTab() ).not.toHaveFocus(); await user.keyboard( '[Tab]' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); // Press the right arrow key three times. Since the delta tab is disabled: @@ -835,7 +835,7 @@ describe.each( [ // - it will receive focus, when using arrow keys await user.keyboard( '[ArrowRight][ArrowRight][ArrowRight]' ); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( + expect( screen.getByRole( 'tab', { name: 'Delta' } ) ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); @@ -846,7 +846,7 @@ describe.each( [ // already selected. await user.keyboard( '[ArrowLeft]' ); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); // Click on the disabled tab. Compared to using arrow keys to move the @@ -854,7 +854,7 @@ describe.each( [ // receive focus, nor they cause the `mockOnSelect` function to fire. await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); - await expect( await getSelectedTab() ).toHaveFocus(); + expect( await getSelectedTab() ).toHaveFocus(); expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); } ); From c97cbee816d2ad67f24eb4720d31f9e843ad253a Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Thu, 13 Jul 2023 16:47:28 -0400 Subject: [PATCH 19/27] move `extractTabName` out of component --- packages/components/src/tab-panel/index.tsx | 22 ++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/components/src/tab-panel/index.tsx b/packages/components/src/tab-panel/index.tsx index d6a95da1e751f5..571362f8f07139 100644 --- a/packages/components/src/tab-panel/index.tsx +++ b/packages/components/src/tab-panel/index.tsx @@ -24,6 +24,17 @@ import Button from '../button'; import type { TabPanelProps } from './types'; import type { WordPressComponentProps } from '../ui/context'; +// Separate the actual tab name from the instance ID. This is +// necessary because Ariakit internally uses the element ID when +// a new tab is selected, but our implementation looks specifically +// for the tab name to be passed to the `onSelect` callback. +const extractTabName = ( id: string | undefined | null ) => { + if ( typeof id === 'undefined' || id === null ) { + return; + } + return id.match( /^tab-panel-[0-9]*-(.*)/ )?.[ 1 ]; +}; + /** * TabPanel is an ARIA-compliant tabpanel. * @@ -85,17 +96,6 @@ const UnforwardedTabPanel = ( [ instanceId ] ); - // Separate the actual tab name from the instance ID. This is - // necessary because Ariakit internally uses the element ID when - // a new tab is selected, but our implementation looks specifically - // for the tab name to be passed to the `onSelect` callback. - const extractTabName = useCallback( ( id: string | undefined | null ) => { - if ( typeof id === 'undefined' || id === null ) { - return; - } - return id.match( /^tab-panel-[0-9]*-(.*)/ )?.[ 1 ]; - }, [] ); - const tabStore = Ariakit.useTabStore( { setSelectedId: ( newTabValue ) => { if ( typeof newTabValue === 'undefined' || newTabValue === null ) { From 00d2eaaa503c68b5788afc27b95383144e5f1ec4 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Mon, 24 Jul 2023 14:28:44 -0400 Subject: [PATCH 20/27] restore original component lazy loading --- packages/components/src/tab-panel/index.tsx | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/packages/components/src/tab-panel/index.tsx b/packages/components/src/tab-panel/index.tsx index 571362f8f07139..a9e66e076fbc70 100644 --- a/packages/components/src/tab-panel/index.tsx +++ b/packages/components/src/tab-panel/index.tsx @@ -190,7 +190,6 @@ const UnforwardedTabPanel = ( setTabStoreSelectedId( firstEnabledTab.name ); } }, [ tabs, selectedTab?.disabled, setTabStoreSelectedId, instanceId ] ); - return (
- { tabs.map( ( tab ) => ( + { selectedTab && ( - { children( tab ) } + { children( selectedTab ) } - ) ) } + ) }
); }; From 4b7d7bb324b4ca7dd91254fa3e0bc48f56da8281 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Mon, 24 Jul 2023 14:33:48 -0400 Subject: [PATCH 21/27] update CHANGELOG entry --- packages/components/CHANGELOG.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 593ca9c6400faf..e441f8a01f92b5 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +### Internal + +- `TabPanel`: Introduce a new version of `TabPanel` with updated internals while maintaining the same functionality and API surface ([#52133](https://github.com/WordPress/gutenberg/pull/52133)). + ## 25.4.0 (2023-07-20) ### Enhancements @@ -16,10 +20,6 @@ - `Toolbar`: Fix toolbar items not being tabbable on the first render. ([#52613](https://github.com/WordPress/gutenberg/pull/52613)) - `FormTokenField`: Fix token overflow when moving cursor left or right. ([#52662](https://github.com/WordPress/gutenberg/pull/52662)) -### Internal - -- `TabPanel`: Introduce a new version of `TabPanel` with updated internals while maintaining the same functionality and API surface ([#52133](https://github.com/WordPress/gutenberg/pull/52133)). - ## 25.3.0 (2023-07-05) ### Enhancements From aa1bfc2ad70a9b00924023baacde01b22385da50 Mon Sep 17 00:00:00 2001 From: chad1008 <13856531+chad1008@users.noreply.github.com> Date: Mon, 24 Jul 2023 15:54:57 -0400 Subject: [PATCH 22/27] up editor preferences modal unit tests --- packages/components/src/tab-panel/index.tsx | 3 +++ .../preferences-modal/test/__snapshots__/index.js.snap | 5 +++++ .../src/components/preferences-modal/test/index.js | 8 ++++---- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/components/src/tab-panel/index.tsx b/packages/components/src/tab-panel/index.tsx index a9e66e076fbc70..2635e0c4b4b9da 100644 --- a/packages/components/src/tab-panel/index.tsx +++ b/packages/components/src/tab-panel/index.tsx @@ -210,6 +210,9 @@ const UnforwardedTabPanel = ( } ) } disabled={ tab.disabled } + aria-controls={ `${ prependInstanceId( + tab.name + ) }-view` } render={