diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx new file mode 100644 index 0000000000000..5d13a05ee6c48 --- /dev/null +++ b/packages/components/src/tabs/test/index.tsx @@ -0,0 +1,941 @@ +/** + * 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 = [ + { + id: 'alpha', + title: 'Alpha', + content: 'Selected tab: Alpha', + tab: { className: 'alpha-class' }, + }, + { + id: 'beta', + title: 'Beta', + content: 'Selected tab: Beta', + tab: { className: 'beta-class' }, + }, + { + id: 'gamma', + title: 'Gamma', + content: 'Selected tab: Gamma', + tab: { 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; + + let instance = 0; + + 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; + } ); + + afterEach( () => { + instance++; + } ); + + describe( 'Accessibility and semantics', () => { + it( 'should use the correct aria attributes', async () => { + render( ); + + const tabList = screen.getByRole( 'tablist' ); + const allTabs = screen.getAllByRole( 'tab' ); + const selectedTabPanel = await screen.findByRole( 'tabpanel' ); + + expect( tabList ).toBeVisible(); + expect( tabList ).toHaveAttribute( + 'aria-orientation', + 'horizontal' + ); + + expect( allTabs ).toHaveLength( TABS.length ); + + // The selected `tab` aria-controls the active `tabpanel`, + // which is `aria-labelledby` the selected `tab`. + expect( selectedTabPanel ).toBeVisible(); + expect( allTabs[ 0 ] ).toHaveAttribute( + 'aria-controls', + selectedTabPanel.getAttribute( 'id' ) + ); + expect( selectedTabPanel ).toHaveAttribute( + 'aria-labelledby', + allTabs[ 0 ].getAttribute( 'id' ) + ); + } ); + + it( 'should display a tooltip when hovering tabs provided with an icon', async () => { + const user = userEvent.setup(); + + const TABS_WITH_ICON = [ + { ...TABS[ 0 ], tab: { ...TABS[ 0 ].tab, icon: wordpress } }, + { ...TABS[ 1 ], tab: { ...TABS[ 0 ].tab, icon: category } }, + { ...TABS[ 2 ], tab: { ...TABS[ 0 ].tab, 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 TABS_WITH_ICON = [ + { ...TABS[ 0 ], tab: { ...TABS[ 0 ].tab, icon: wordpress } }, + { ...TABS[ 1 ], tab: { ...TABS[ 0 ].tab, icon: category } }, + { ...TABS[ 2 ], tab: { ...TABS[ 0 ].tab, 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( + `tabs-${ instance }-alpha` + ); + expect( await getSelectedTab() ).not.toHaveFocus(); + + // Tab to focus the tablist. Make sure alpha is focused, and that the + // corresponding tooltip is shown. + expect( screen.queryByText( 'Alpha' ) ).not.toBeInTheDocument(); + await user.keyboard( '[Tab]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( screen.getByText( 'Alpha' ) ).toBeInTheDocument(); + expect( await getSelectedTab() ).toHaveFocus(); + + // Move selection with arrow keys. Make sure beta is focused, and that + // the corresponding tooltip is shown. + expect( screen.queryByText( 'Beta' ) ).not.toBeInTheDocument(); + await user.keyboard( '[ArrowRight]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-beta` + ); + expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); + expect( await getSelectedTab() ).toHaveFocus(); + + // Move selection with arrow keys. Make sure gamma is focused, and that + // the corresponding tooltip is shown. + expect( screen.queryByText( 'Gamma' ) ).not.toBeInTheDocument(); + await user.keyboard( '[ArrowRight]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-gamma` + ); + expect( screen.getByText( 'Gamma' ) ).toBeInTheDocument(); + expect( await getSelectedTab() ).toHaveFocus(); + + // Move selection with arrow keys. Make sure beta is focused, and that + // the corresponding tooltip is shown. + expect( screen.queryByText( 'Beta' ) ).not.toBeInTheDocument(); + await user.keyboard( '[ArrowLeft]' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-beta` + ); + expect( screen.getByText( 'Beta' ) ).toBeInTheDocument(); + expect( await getSelectedTab() ).toHaveFocus(); + } ); + } ); + + describe( 'Without `initialTabId`', () => { + it( 'should render first tab', async () => { + render( ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + } ); + + it( 'should fall back to first enabled tab if the active tab is removed', async () => { + const mockOnSelect = jest.fn(); + const { rerender } = render( + + ); + + rerender( + + ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + } ); + + describe( 'With `initialTabId`', () => { + it( 'should render the tab set by initialTabId prop', async () => { + render( ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should not select a tab when `initialTabId` does not match any known tab', () => { + render( ); + + // No tab should be selected i.e. it doesn't fall back to first tab. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + + // No tabpanel should be rendered either + expect( screen.queryByRole( 'tabpanel' ) ).not.toBeInTheDocument(); + } ); + it( 'should not change tabs when initialTabId is changed', async () => { + const { rerender } = render( + + ); + + rerender( ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should fall back to the tab associated to `initialTabId` if the currently active tab is removed', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + // expect( mockOnSelect ).toHaveBeenLastCalledWith( + // `tabs-${ instance }-gamma` + // ); + + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-alpha` + ); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + // expect( mockOnSelect ).toHaveBeenLastCalledWith( + // `tabs-${ instance }-gamma` + // ); + } ); + + it( 'should have no active tabs when the tab associated to `initialTabId` is removed while being the active tab', async () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + rerender( + + ); + + expect( screen.getAllByRole( 'tab' ) ).toHaveLength( 2 ); + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + } ); + + it( 'waits for the tab with the `initialTabId` to be present in the `tabs` array before selecting it', async () => { + const mockOnSelect = jest.fn(); + const { rerender } = render( + + ); + + // There should be no selected tab yet. + expect( + screen.queryByRole( 'tab', { selected: true } ) + ).not.toBeInTheDocument(); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Delta' ); + } ); + } ); + + describe( 'Disabled Tab', () => { + it( 'should disable the tab when `disabled` is `true`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + + ); + + 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.id !== 'alpha' ) { + return tab; + } + return { ...tab, tab: { ...tab.tab, disabled: true } }; + } ) } + onSelect={ mockOnSelect } + /> + ); + + // As alpha (first tab) is disabled, + // the first enabled tab should be beta. + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + + // Re-enable all tabs + rerender( ); + + // Even if the initial tab becomes enabled again, the selected tab doesn't + // change. + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + } ); + + it( 'should select first enabled tab when the tab associated to `initialTabId` is disabled', async () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + { + if ( tab.id === 'gamma' ) { + return tab; + } + return { ...tab, tab: { ...tab.tab, disabled: true } }; + } ) } + initialTabId="beta" + 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( + + ); + + // Even if the initial tab becomes enabled again, the selected tab doesn't + // change. + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + } ); + + it( 'should select the first enabled tab when the selected tab becomes disabled', async () => { + const mockOnSelect = jest.fn(); + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-alpha` + ); + + rerender( + { + if ( tab.id === 'alpha' ) { + return { + ...tab, + tab: { ...tab.tab, disabled: true }, + }; + } + return tab; + } ) } + onSelect={ mockOnSelect } + /> + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-beta` + ); + + rerender( ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-beta` + ); + } ); + + it( 'should select the first enabled tab when the tab associated to `initialTabId` becomes disabled while being the active tab', async () => { + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-alpha` + ); + + rerender( + + ); + + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + } ); + } ); + + describe( 'Tab Activation', () => { + it( 'defaults to automatic tab activation (pointer clicks)', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( ); + + // Alpha is the initially selected tab + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( + await screen.findByRole( 'tabpanel', { name: 'Alpha' } ) + ).toBeInTheDocument(); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-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( + `tabs-${ instance }-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( + `tabs-${ instance }-alpha` + ); + } ); + + it( 'defaults to automatic tab activation (arrow keys)', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Navigate forward with arrow keys and make sure the Beta tab is + // selected automatically. + await user.keyboard( '[ArrowRight]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-beta` + ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowLeft]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-alpha` + ); + } ); + + it( 'wraps around the last/first tab when using arrow keys', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure Alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Navigate backwards with arrow keys and make sure that the Gamma tab + // (the last tab) is selected automatically. + await user.keyboard( '[ArrowLeft]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-gamma` + ); + + // Navigate forward with arrow keys. Make sure alpha (the first tab) is + // selected automatically. + await user.keyboard( '[ArrowRight]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-alpha` + ); + } ); + + it( 'should not move tab selection when pressing the up/down arrow keys, unless the orientation is changed to `vertical`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + const { rerender } = render( + + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Press the arrow up key, nothing happens. + await user.keyboard( '[ArrowUp]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-alpha` + ); + + // Press the arrow down key, nothing happens + await user.keyboard( '[ArrowDown]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-alpha` + ); + + // Change orientation to `vertical`. When the orientation is vertical, + // left/right arrow keys are replaced by up/down arrow keys. + rerender( + + ); + + expect( screen.getByRole( 'tablist' ) ).toHaveAttribute( + 'aria-orientation', + 'vertical' + ); + + // Make sure alpha is still focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + + // Navigate forward with arrow keys and make sure the Beta tab is + // selected automatically. + await user.keyboard( '[ArrowDown]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 2 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-beta` + ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowUp]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-alpha` + ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowUp]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 4 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-gamma` + ); + + // Navigate backwards with arrow keys. Make sure alpha is + // selected automatically. + await user.keyboard( '[ArrowDown]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 5 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-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( + + ); + + // onSelect gets called on the initial render. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Tab to focus the tablist. Make sure Alpha is focused. + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).not.toHaveFocus(); + await user.keyboard( '[Tab]' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Press the right arrow key three times. Since the delta tab is disabled: + // - it won't be selected. The gamma tab will be selected instead, since + // it was the tab that was last selected before delta. Therefore, the + // `mockOnSelect` function gets called only twice (and not three times) + // - it will receive focus, when using arrow keys + await user.keyboard( '[ArrowRight][ArrowRight][ArrowRight]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( + screen.getByRole( 'tab', { name: 'Delta' } ) + ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-gamma` + ); + + // Navigate backwards with arrow keys. The gamma tab receives focus. + // The `mockOnSelect` callback doesn't fire, since the gamma tab was + // already selected. + await user.keyboard( '[ArrowLeft]' ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + + // Click on the disabled tab. Compared to using arrow keys to move the + // focus, disabled tabs ignore pointer clicks — and therefore, they don't + // receive focus, nor they cause the `mockOnSelect` function to fire. + await user.click( screen.getByRole( 'tab', { name: 'Delta' } ) ); + expect( await getSelectedTab() ).toHaveTextContent( 'Gamma' ); + expect( await getSelectedTab() ).toHaveFocus(); + expect( mockOnSelect ).toHaveBeenCalledTimes( 3 ); + } ); + + it( 'should not focus the next tab when the Tab key is pressed', async () => { + const user = userEvent.setup(); + + render( ); + + // Tab should initially focus the first tab in the tablist, which + // is Alpha. + await user.keyboard( '[Tab]' ); + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveFocus(); + + // Because all other tabs should have `tabindex=-1`, pressing Tab + // should NOT move the focus to the next tab, which is Beta. + await user.keyboard( '[Tab]' ); + expect( + await screen.findByRole( 'tab', { name: 'Beta' } ) + ).not.toHaveFocus(); + } ); + + it( 'switches to manual tab activation when the `selectOnMove` prop is set to `false`', async () => { + const user = userEvent.setup(); + const mockOnSelect = jest.fn(); + + render( + + ); + + // onSelect gets called on the initial render with the default + // selected tab. + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + + // Click on Alpha and make sure it is selected. + // onSelect shouldn't fire since the selected tab didn't change. + await user.click( screen.getByRole( 'tab', { name: 'Alpha' } ) ); + expect( mockOnSelect ).toHaveBeenCalledTimes( 1 ); + expect( mockOnSelect ).toHaveBeenLastCalledWith( + `tabs-${ instance }-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( + `tabs-${ instance }-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( + `tabs-${ instance }-gamma` + ); + } ); + } ); + + describe( 'Tab Attributes', () => { + it( "should apply the tab's `className` to the tab button", async () => { + render( ); + + expect( + await screen.findByRole( 'tab', { name: 'Alpha' } ) + ).toHaveClass( 'alpha-class' ); + expect( screen.getByRole( 'tab', { name: 'Beta' } ) ).toHaveClass( + 'beta-class' + ); + expect( screen.getByRole( 'tab', { name: 'Gamma' } ) ).toHaveClass( + 'gamma-class' + ); + } ); + + it( 'should apply the `activeClass` to the selected tab', async () => { + const user = userEvent.setup(); + const activeClass = 'my-active-tab'; + + render( ); + + // Make sure that only the selected tab has the active class + expect( await getSelectedTab() ).toHaveTextContent( 'Alpha' ); + expect( await getSelectedTab() ).toHaveClass( activeClass ); + screen + .getAllByRole( 'tab', { selected: false } ) + .forEach( ( unselectedTab ) => { + expect( unselectedTab ).not.toHaveClass( activeClass ); + } ); + + // Click the 'Beta' tab + await user.click( screen.getByRole( 'tab', { name: 'Beta' } ) ); + + // Make sure that only the selected tab has the active class + expect( await getSelectedTab() ).toHaveTextContent( 'Beta' ); + expect( await getSelectedTab() ).toHaveClass( activeClass ); + screen + .getAllByRole( 'tab', { selected: false } ) + .forEach( ( unselectedTab ) => { + expect( unselectedTab ).not.toHaveClass( activeClass ); + } ); + } ); + } ); +} );