diff --git a/packages/components/src/style.scss b/packages/components/src/style.scss index 39c566be5ad526..9dec227a7cdc04 100644 --- a/packages/components/src/style.scss +++ b/packages/components/src/style.scss @@ -37,6 +37,7 @@ @import "./select-control/style.scss"; @import "./snackbar/style.scss"; @import "./spinner/style.scss"; +@import "./tab-panel/style.scss"; @import "./text-control/style.scss"; @import "./textarea-control/style.scss"; @import "./toggle-control/style.scss"; diff --git a/packages/components/src/tab-panel/README.md b/packages/components/src/tab-panel/README.md index def626516b3a39..2ca75d149d4a1e 100644 --- a/packages/components/src/tab-panel/README.md +++ b/packages/components/src/tab-panel/README.md @@ -119,6 +119,7 @@ An array of objects containing the following properties: - `name`: `(string)` Defines the key for the tab. - `title`:`(string)` Defines the translated text for the tab. - `className`:`(string)` Optional. Defines the class to put on the tab. +- `onSelect`:`(function)` Optional. The function called when the tab is selected >> **Note:** Other fields may be added to the object and accessed from the child function if desired. @@ -133,9 +134,9 @@ The class to add to the active tab - Required: No - Default: `is-active` -#### initialTabName +#### controlledTabName -Optionally provide a tab name for a tab to be selected upon mounting of component. If this prop is not set, the first tab will be selected by default. +Provide a tab name for a tab to be selected upon mounting and controlled throughout the component's lifecycle. If this prop is not set, the first tab will be selected by default. - Type: `String` - Required: No @@ -147,4 +148,4 @@ A function which renders the tabviews given the selected tab. The function is pa The element to which the tooltip should anchor. - Type: (`Object`) => `Element` -- Required: Yes +- Required: No diff --git a/packages/components/src/tab-panel/index.js b/packages/components/src/tab-panel/index.js index 71f4683bcbef80..77ac0c6ed1a1f8 100644 --- a/packages/components/src/tab-panel/index.js +++ b/packages/components/src/tab-panel/index.js @@ -2,13 +2,14 @@ * External dependencies */ import classnames from 'classnames'; -import { partial, noop, find } from 'lodash'; +import { noop, find } from 'lodash'; /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; import { withInstanceId } from '@wordpress/compose'; +import { useState, useEffect } from '@wordpress/element'; /** * Internal dependencies @@ -28,77 +29,64 @@ const TabButton = ( { tabId, onClick, children, selected, ...rest } ) => ( ); -class TabPanel extends Component { - constructor() { - super( ...arguments ); - const { tabs, initialTabName } = this.props; +const TabPanel = ( { tabs, initialTabName, controlledTabName, className, onSelect = noop, activeClass = 'is-active', orientation = 'horizontal', instanceId, children } ) => { + const [ selectedTabName, setSelectedTabName ] = useState( initialTabName || controlledTabName || tabs[ 0 ].name ); + const selectedTab = find( tabs, { name: selectedTabName } ); + const selectedId = selectedTab ? instanceId + '-' + selectedTab.name : ''; - this.handleClick = this.handleClick.bind( this ); - this.onNavigate = this.onNavigate.bind( this ); + const onClick = ( tab ) => { + onSelect( tab.name ); + setSelectedTabName( tab.name ); + if ( tab.onSelect ) { + tab.onSelect(); + } + }; - this.state = { - selected: initialTabName || ( tabs.length > 0 ? tabs[ 0 ].name : null ), - }; - } - - handleClick( tabKey ) { - const { onSelect = noop } = this.props; - this.setState( { - selected: tabKey, - } ); - onSelect( tabKey ); - } - - onNavigate( childIndex, child ) { + const onNavigate = ( childIndex, child ) => { child.click(); - } - - render() { - const { selected } = this.state; - const { - activeClass = 'is-active', - className, - instanceId, - orientation = 'horizontal', - tabs, - } = this.props; + }; - const selectedTab = find( tabs, { name: selected } ); - const selectedId = instanceId + '-' + selectedTab.name; + useEffect( + () => { + if ( controlledTabName ) { + setSelectedTabName( controlledTabName ); + } + }, [ controlledTabName ] + ); - return ( -
- - { tabs.map( ( tab ) => ( - - { tab.title } - - ) ) } - - { selectedTab && ( -
+ + { tabs.map( ( tab ) => ( + onClick( tab ) } > - { this.props.children( selectedTab ) } -
- ) } -
- ); - } -} + { tab.title } + + ) ) } + + { children && selectedTab && ( +
+ { children( selectedTab ) } +
+ ) } + + ); +}; export default withInstanceId( TabPanel ); diff --git a/packages/components/src/tab-panel/style.scss b/packages/components/src/tab-panel/style.scss new file mode 100644 index 00000000000000..da1ae369c99450 --- /dev/null +++ b/packages/components/src/tab-panel/style.scss @@ -0,0 +1,46 @@ +.components-tab-panel__tabs { + display: flex; + align-items: stretch; + background: $light-gray-200; + border-bottom: 1px solid #e2e4e7; +} + +.components-tab-panel__tabs-item { + background: transparent; + border: none; + box-shadow: none; + cursor: pointer; + height: 50px; + line-height: 42px; + padding: 3px 15px; // Use padding to offset the is-active border, this benefits Windows High Contrast mode + margin-left: 0; + font-weight: 400; + @include square-style__neutral; + transition: box-shadow 0.1s linear; + + &:focus:enabled { + color: $dark-gray-900; + outline-offset: -1px; + outline: 1px dotted $dark-gray-500; + } + + &:focus:enabled, + &.is-active { + box-shadow: inset 0 -3px theme(outlines); + font-weight: 600; + position: relative; + background: transparent; + + // This border appears in Windows High Contrast mode instead of the box-shadow. + &::before { + content: ""; + position: absolute; + top: 0; + bottom: 1px; + right: 0; + left: 0; + border-bottom: 3px solid transparent; + } + } + +} diff --git a/packages/components/src/tab-panel/test/index.js b/packages/components/src/tab-panel/test/index.js index 153441cd195316..78630dcfdf8011 100644 --- a/packages/components/src/tab-panel/test/index.js +++ b/packages/components/src/tab-panel/test/index.js @@ -120,11 +120,11 @@ describe( 'TabPanel', () => { } ); } ); - it( 'should render with a tab initially selected by prop initialTabIndex', () => { + it( 'should render with a tab initially selected by prop controlledTabName', () => { const props = { className: 'test-panel', activeClass: 'active-tab', - initialTabName: 'beta', + controlledTabName: 'beta', tabs: [ { name: 'alpha', diff --git a/packages/e2e-tests/specs/block-hierarchy-navigation.test.js b/packages/e2e-tests/specs/block-hierarchy-navigation.test.js index 3f0e1c8c3f55c7..461edc082839ca 100644 --- a/packages/e2e-tests/specs/block-hierarchy-navigation.test.js +++ b/packages/e2e-tests/specs/block-hierarchy-navigation.test.js @@ -81,7 +81,7 @@ describe( 'Navigating the block hierarchy', () => { await pressKeyWithModifier( 'ctrl', '`' ); await pressKeyWithModifier( 'ctrl', '`' ); await pressKeyWithModifier( 'ctrl', '`' ); - await pressKeyTimes( 'Tab', 4 ); + await pressKeyTimes( 'Tab', 3 ); // Tweak the columns count by increasing it by one. await page.keyboard.press( 'ArrowRight' ); diff --git a/packages/e2e-tests/specs/editor-modes.test.js b/packages/e2e-tests/specs/editor-modes.test.js index 4096338ce9f64b..ce1ec41866750f 100644 --- a/packages/e2e-tests/specs/editor-modes.test.js +++ b/packages/e2e-tests/specs/editor-modes.test.js @@ -90,7 +90,7 @@ describe( 'Editing modes (visual/HTML)', () => { expect( title ).toBe( 'Paragraph' ); // The Block inspector should be active - let blockInspectorTab = await page.$( '.edit-post-sidebar__panel-tab.is-active[data-label="Block"]' ); + let blockInspectorTab = await page.$( '.edit-post-sidebar__panel-tab.is-active[aria-label="Block (selected)"]' ); expect( blockInspectorTab ).not.toBeNull(); // Switch to Code Editor and hide More Menu @@ -100,11 +100,11 @@ describe( 'Editing modes (visual/HTML)', () => { ); // The Block inspector should not be active anymore - blockInspectorTab = await page.$( '.edit-post-sidebar__panel-tab.is-active[data-label="Block"]' ); + blockInspectorTab = await page.$( '.edit-post-sidebar__panel-tab.is-active[aria-label="Block"]' ); expect( blockInspectorTab ).toBeNull(); // No block is selected - await page.click( '.edit-post-sidebar__panel-tab[data-label="Block"]' ); + await page.click( '.edit-post-sidebar__panel-tab[aria-label="Block"]' ); const noBlocksElement = await page.$( '.block-editor-block-inspector__no-blocks' ); expect( noBlocksElement ).not.toBeNull(); diff --git a/packages/e2e-tests/specs/sidebar.test.js b/packages/e2e-tests/specs/sidebar.test.js index def5d0f5011413..cbbf51f0bd0645 100644 --- a/packages/e2e-tests/specs/sidebar.test.js +++ b/packages/e2e-tests/specs/sidebar.test.js @@ -96,8 +96,7 @@ describe( 'Sidebar', () => { expect( isActiveDocumentTab ).toBe( true ); // Tab into and activate "Block". - await page.keyboard.press( 'Tab' ); - await page.keyboard.press( 'Space' ); + await page.keyboard.press( 'ArrowRight' ); const isActiveBlockTab = await page.evaluate( () => ( document.activeElement.textContent === 'Block' && document.activeElement.classList.contains( 'is-active' ) diff --git a/packages/edit-post/src/components/sidebar/settings-header/index.js b/packages/edit-post/src/components/sidebar/settings-header/index.js index 04132d6f2664ea..d40533cf14b31c 100644 --- a/packages/edit-post/src/components/sidebar/settings-header/index.js +++ b/packages/edit-post/src/components/sidebar/settings-header/index.js @@ -3,6 +3,7 @@ */ import { __ } from '@wordpress/i18n'; import { withDispatch } from '@wordpress/data'; +import { TabPanel } from '@wordpress/components'; /** * Internal dependencies @@ -10,47 +11,33 @@ import { withDispatch } from '@wordpress/data'; import SidebarHeader from '../sidebar-header'; const SettingsHeader = ( { openDocumentSettings, openBlockSettings, sidebarName } ) => { - const blockLabel = __( 'Block' ); - const [ documentAriaLabel, documentActiveClass ] = sidebarName === 'edit-post/document' ? - // translators: ARIA label for the Document sidebar tab, selected. - [ __( 'Document (selected)' ), 'is-active' ] : - // translators: ARIA label for the Document sidebar tab, not selected. - [ __( 'Document' ), '' ]; - - const [ blockAriaLabel, blockActiveClass ] = sidebarName === 'edit-post/block' ? - // translators: ARIA label for the Settings Sidebar tab, selected. - [ __( 'Block (selected)' ), 'is-active' ] : - // translators: ARIA label for the Settings Sidebar tab, not selected. - [ __( 'Block' ), '' ]; + const tabs = [ + { + name: 'edit-post/document', + className: 'edit-post-sidebar__panel-tab', + title: __( 'Document' ), + ariaLabel: __( 'Document' ), + onSelect: openDocumentSettings, + }, + { + name: 'edit-post/block', + className: 'edit-post-sidebar__panel-tab', + title: __( 'Block' ), + ariaLabel: __( 'Block' ), + onSelect: openBlockSettings, + }, + ]; return ( - { /* Use a list so screen readers will announce how many tabs there are. */ } - + + ); }; diff --git a/packages/edit-post/src/components/sidebar/settings-header/style.scss b/packages/edit-post/src/components/sidebar/settings-header/style.scss index 73d9431661c267..85363b2f4d34be 100644 --- a/packages/edit-post/src/components/sidebar/settings-header/style.scss +++ b/packages/edit-post/src/components/sidebar/settings-header/style.scss @@ -6,59 +6,4 @@ position: sticky; z-index: z-index(".components-panel__header.edit-post-sidebar__panel-tabs"); top: 0; - - ul { - display: flex; - } - li { - margin: 0; - } -} - -.edit-post-sidebar__panel-tab { - background: transparent; - border: none; - box-shadow: none; - cursor: pointer; - padding: 3px 15px; // Use padding to offset the is-active border, this benefits Windows High Contrast mode - margin-left: 0; - font-weight: 400; - color: $dark-gray-900; - @include square-style__neutral; - transition: box-shadow 0.1s linear; - @include reduce-motion("transition"); - - // This pseudo-element "duplicates" the tab label and sets the text to bold. - // This ensures that the tab doesn't change width when selected. - // See: https://github.com/WordPress/gutenberg/pull/9793 - &::after { - content: attr(data-label); - display: block; - font-weight: 600; - height: 0; - overflow: hidden; - speak: none; - visibility: hidden; - } - - &.is-active { - box-shadow: inset 0 -3px theme(outlines); - font-weight: 600; - position: relative; - - // This border appears in Windows High Contrast mode instead of the box-shadow. - &::before { - content: ""; - position: absolute; - top: 0; - bottom: 1px; - right: 0; - left: 0; - border-bottom: 3px solid transparent; - } - } - - &:focus { - @include square-style__focus; - } }