diff --git a/packages/components/CHANGELOG.md b/packages/components/CHANGELOG.md index 72edad0df4e10e..8dd8d4b552d9d8 100644 --- a/packages/components/CHANGELOG.md +++ b/packages/components/CHANGELOG.md @@ -6,6 +6,10 @@ - Migrate `Divider` from `reakit` to `ariakit` ([#55622](https://github.com/WordPress/gutenberg/pull/55622)) +### Experimental + +- `Tabs`: Add `focusable` prop to the `Tabs.TabPanel` sub-component ([#55287](https://github.com/WordPress/gutenberg/pull/55287)) + ### Enhancements - `ToggleGroupControl`: Add opt-in prop for 40px default size ([#55789](https://github.com/WordPress/gutenberg/pull/55789)). diff --git a/packages/components/src/tabs/README.md b/packages/components/src/tabs/README.md index 6907f385fda371..8fb4e0e73caec2 100644 --- a/packages/components/src/tabs/README.md +++ b/packages/components/src/tabs/README.md @@ -240,3 +240,10 @@ The class name to apply to the tabpanel. Custom CSS styles for the tab. - Required: No + +###### `focusable`: `boolean` + +Determines whether or not the tabpanel element should be focusable. If `false`, pressing the tab key will skip over the tabpanel, and instead focus on the first focusable element in the panel (if there is one). + +- Required: No +- Default: `true` diff --git a/packages/components/src/tabs/stories/index.story.tsx b/packages/components/src/tabs/stories/index.story.tsx index 3b6ba022f6d91b..08e29589881707 100644 --- a/packages/components/src/tabs/stories/index.story.tsx +++ b/packages/components/src/tabs/stories/index.story.tsx @@ -42,8 +42,16 @@ const Template: StoryFn< typeof Tabs > = ( props ) => {

Selected tab: Tab 2

- +

Selected tab: Tab 3

+

+ This tabpanel has its focusable prop set to + false, so it won't get a tab stop. +
+ Instead, the [Tab] key will move focus to the first + focusable element within the panel. +

+
); diff --git a/packages/components/src/tabs/styles.ts b/packages/components/src/tabs/styles.ts index 091ba608fb6ecd..cb735f3177662a 100644 --- a/packages/components/src/tabs/styles.ts +++ b/packages/components/src/tabs/styles.ts @@ -101,3 +101,19 @@ export const Tab = styled( Ariakit.Tab )` } } `; + +export const TabPanel = styled( Ariakit.TabPanel )` + &:focus { + box-shadow: none; + outline: none; + } + + &:focus-visible { + border-radius: 2px; + box-shadow: 0 0 0 var( --wp-admin-border-width-focus ) + ${ COLORS.theme.accent }; + // Windows high contrast mode. + outline: 2px solid transparent; + outline-offset: 0; + } +`; diff --git a/packages/components/src/tabs/tabpanel.tsx b/packages/components/src/tabs/tabpanel.tsx index fb62fc91912331..b5339141a56eca 100644 --- a/packages/components/src/tabs/tabpanel.tsx +++ b/packages/components/src/tabs/tabpanel.tsx @@ -1,8 +1,6 @@ /** * External dependencies */ -// eslint-disable-next-line no-restricted-imports -import * as Ariakit from '@ariakit/react'; /** * WordPress dependencies @@ -14,12 +12,16 @@ import { forwardRef, useContext } from '@wordpress/element'; * Internal dependencies */ import type { TabPanelProps } from './types'; +import { TabPanel as StyledTabPanel } from './styles'; import warning from '@wordpress/warning'; import { TabsContext } from './context'; export const TabPanel = forwardRef< HTMLDivElement, TabPanelProps >( - function TabPanel( { children, id, className, style }, ref ) { + function TabPanel( + { children, id, className, style, focusable = true }, + ref + ) { const context = useContext( TabsContext ); if ( ! context ) { warning( '`Tabs.TabPanel` must be wrapped in a `Tabs` component.' ); @@ -28,7 +30,8 @@ export const TabPanel = forwardRef< HTMLDivElement, TabPanelProps >( const { store, instanceId } = context; return ( - ( className={ className } > { children } - + ); } ); diff --git a/packages/components/src/tabs/test/index.tsx b/packages/components/src/tabs/test/index.tsx index a89e680e244d8c..67b7bf588e74e8 100644 --- a/packages/components/src/tabs/test/index.tsx +++ b/packages/components/src/tabs/test/index.tsx @@ -26,6 +26,9 @@ type Tab = { icon?: IconType; disabled?: boolean; }; + tabpanel?: { + focusable?: boolean; + }; }; const TABS: Tab[] = [ @@ -83,7 +86,11 @@ const UncontrolledTabs = ( { ) ) } { tabs.map( ( tabObj ) => ( - + { tabObj.content } ) ) } @@ -184,6 +191,63 @@ describe( 'Tabs', () => { ); } ); } ); + describe( 'Focus Behavior', () => { + it( 'should focus on the related TabPanel when pressing the Tab key', async () => { + const user = userEvent.setup(); + + render( ); + + const selectedTabPanel = await screen.findByRole( 'tabpanel' ); + + // 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(); + + // By default the tabpanel should receive focus + await user.keyboard( '[Tab]' ); + expect( selectedTabPanel ).toHaveFocus(); + } ); + it( 'should not focus on the related TabPanel when pressing the Tab key if `focusable: false` is set', async () => { + const user = userEvent.setup(); + + const TABS_WITH_ALPHA_FOCUSABLE_FALSE = TABS.map( ( tabObj ) => + tabObj.id === 'alpha' + ? { + ...tabObj, + content: ( + <> + Selected Tab: Alpha + + + ), + tabpanel: { focusable: false }, + } + : tabObj + ); + + render( + + ); + + const alphaButton = await screen.findByRole( 'button', { + name: /alpha button/i, + } ); + + // 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 the alpha tabpanel is set to `focusable: false`, pressing + // the Tab key should focus the button, not the tabpanel + await user.keyboard( '[Tab]' ); + expect( alphaButton ).toHaveFocus(); + } ); + } ); describe( 'Tab Attributes', () => { it( "should apply the tab's `className` to the tab button", async () => { diff --git a/packages/components/src/tabs/types.ts b/packages/components/src/tabs/types.ts index 88e25eb5a3863c..9874fe6cb6ccfa 100644 --- a/packages/components/src/tabs/types.ts +++ b/packages/components/src/tabs/types.ts @@ -128,7 +128,7 @@ export type TabPanelProps = { */ children?: React.ReactNode; /** - * A unique identifier for the TabPanel, which is used to generate a unique `id` for the underlying element. + * A unique identifier for the tabpanel, which is used to generate a unique `id` for the underlying element. */ id: string; /** @@ -139,4 +139,12 @@ export type TabPanelProps = { * Custom CSS styles for the rendered `TabPanel` component. */ style?: React.CSSProperties; + /** + * Determines whether or not the tabpanel element should be focusable. + * If `false`, pressing the tab key will skip over the tabpanel, and instead + * focus on the first focusable element in the panel (if there is one). + * + * @default true + */ + focusable?: boolean; };