Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use TabPanel in edit settings sidebar header (#13587) #16663

Closed
wants to merge 7 commits into from
Closed
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/components/src/style.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
12 changes: 11 additions & 1 deletion packages/components/src/tab-panel/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,17 @@ The class to add to the active tab
- Required: No
- Default: `is-active`

#### initialTabName
#### controlledTabName
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a breaking change and can't be landed as is. It needs to provide a way which ensures that this code is backward compatible with a solid deprecation strategy which informs developers that there is a new way to handle this prop.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the review @gziolo, noted.

I added support for the initialTabName for backwards compatibility, as well deprecation notes in the README. However, I lack context as to the timing / version to officially deprecate this prop.

This is also assuming that having a prop to control the active tab from a parent component is a good idea / not an anti-pattern.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we don't want to keep this param, we should rather remove it from docs and ensure that when used it works as close as possible as the new one.


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
- Default: none

#### *initialTabName (To be deprecated)*

*Note: This prop is considered legacy. Please use `controlledTabName` instead. Using this prop in conjunction with `controlledTabName` will result in this prop's value being ignored / overwritten.*

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.

Expand Down
120 changes: 52 additions & 68 deletions packages/components/src/tab-panel/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@
* 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 { withInstanceId } from '@wordpress/compose';
import { useState, useEffect } from '@wordpress/element';

/**
* Internal dependencies
Expand All @@ -28,77 +28,61 @@ const TabButton = ( { tabId, onClick, children, selected, ...rest } ) => (
</Button>
);

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 = noop } ) => {
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 = ( tabName ) => {
setSelectedTabName( tabName );
onSelect( tabName );
};

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 (
<div className={ className }>
<NavigableMenu
role="tablist"
orientation={ orientation }
onNavigate={ this.onNavigate }
className="components-tab-panel__tabs"
>
{ tabs.map( ( tab ) => (
<TabButton className={ classnames( tab.className, { [ activeClass ]: tab.name === selected } ) }
tabId={ instanceId + '-' + tab.name }
aria-controls={ instanceId + '-' + tab.name + '-view' }
selected={ tab.name === selected }
key={ tab.name }
onClick={ partial( this.handleClick, tab.name ) }
>
{ tab.title }
</TabButton>
) ) }
</NavigableMenu>
{ selectedTab && (
<div aria-labelledby={ selectedId }
role="tabpanel"
id={ selectedId + '-view' }
className="components-tab-panel__tab-content"
tabIndex="0"
return (
<div className={ className }>
<NavigableMenu
role="tablist"
orientation={ orientation }
onNavigate={ onNavigate }
className="components-tab-panel__tabs"
>
{ tabs.map( ( tab ) => (
<TabButton className={ classnames( 'components-tab-panel__tabs-item', tab.className, { [ activeClass ]: tab.name === selectedTabName } ) }
tabId={ instanceId + '-' + tab.name }
aria-controls={ instanceId + '-' + tab.name + '-view' }
aria-label={ tab.ariaLabel }
selected={ tab.name === selectedTabName }
key={ tab.name }
onClick={ () => onClick( tab.name ) }
>
{ this.props.children( selectedTab ) }
</div>
) }
</div>
);
}
}
{ tab.title }
</TabButton>
) ) }
</NavigableMenu>
{ selectedTab && (
<div aria-labelledby={ selectedId }
role="tabpanel"
id={ selectedId + '-view' }
className="components-tab-panel__tab-content"
tabIndex="0"
>
{ children( selectedTab ) }
</div>
) }
</div>
);
};

export default withInstanceId( TabPanel );
46 changes: 46 additions & 0 deletions packages/components/src/tab-panel/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
.components-tab-panel__tabs {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mapk are we fine with these styles to be the default look outside of the context of sidebar and WordPress in general?

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;
}
}

}
4 changes: 2 additions & 2 deletions packages/components/src/tab-panel/test/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
6 changes: 3 additions & 3 deletions packages/e2e-tests/specs/editor-modes.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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();

Expand Down
3 changes: 1 addition & 2 deletions packages/e2e-tests/specs/sidebar.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' )
Expand Down
90 changes: 60 additions & 30 deletions packages/edit-post/src/components/sidebar/settings-header/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,54 +3,84 @@
*/
import { __ } from '@wordpress/i18n';
import { withDispatch } from '@wordpress/data';
import { TabPanel } from '@wordpress/components';
import { useState, useEffect } from '@wordpress/element';

/**
* Internal dependencies
*/
import SidebarHeader from '../sidebar-header';

const SettingsHeader = ( { openDocumentSettings, openBlockSettings, sidebarName } ) => {
const blockLabel = __( 'Block' );
const [ documentAriaLabel, documentActiveClass ] = sidebarName === 'edit-post/document' ?
const [ documentAriaLabel, documentSettingsActive ] = sidebarName === 'edit-post/document' ?
// translators: ARIA label for the Document sidebar tab, selected.
[ __( 'Document (selected)' ), 'is-active' ] :
[ __( 'Document (selected)' ), true ] :
// translators: ARIA label for the Document sidebar tab, not selected.
[ __( 'Document' ), '' ];
[ __( 'Document' ), false ];

const [ blockAriaLabel, blockActiveClass ] = sidebarName === 'edit-post/block' ?
const [ blockAriaLabel, blockSettingsActive ] = sidebarName === 'edit-post/block' ?
// translators: ARIA label for the Settings Sidebar tab, selected.
[ __( 'Block (selected)' ), 'is-active' ] :
[ __( 'Block (selected)' ), true ] :
// translators: ARIA label for the Settings Sidebar tab, not selected.
[ __( 'Block' ), '' ];
[ __( 'Block' ), false ];

const documentSettingsName = 'edit-post-settings__panel-tab-document';
const blockSettingsName = 'edit-post-settings__panel-tab-block';

const [ selectedTabName, setSelectedTabName ] = useState( documentSettingsName );

const tabs = [
{
name: documentSettingsName,
className: 'edit-post-sidebar__panel-tab',
title: __( 'Document' ),
ariaLabel: `${ documentAriaLabel }`,
},
{
name: blockSettingsName,
className: 'edit-post-sidebar__panel-tab',
title: __( 'Block' ),
ariaLabel: `${ blockAriaLabel }`,
},
];

const onSelect = ( tabName ) => {
setSelectedTabName( tabName );
if ( tabName === documentSettingsName ) {
openDocumentSettings();
} else if ( tabName === blockSettingsName ) {
openBlockSettings();
}
};

useEffect(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did you consider adding onSelect to the objects passed to tabs instead? It might simplify the solution as you could leave the whole logic which reasons which tab is selected to TabPanel component.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had considered it, but avoided that route as I was originally trying to minimize changes to TabPanel. I agree adding an onSelect method to the tab objects would simplify the solution. (We would still need to support the onSelect component handler for backwards compatibility)

The issue that led me to refactor TabPanel was the need to control the selected tab from the parent component without triggering a re-render of the entire component, causing the active tab to lose focus.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The issue that led me to refactor TabPanel was the need to control the selected tab from the parent component without triggering a re-render of the entire component, causing the active tab to lose focus.

Shouldn't the component ensure that there is never focus loss? 3rd party developers shouldn't care about it, it should be baked in into such component.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will think about that, thank you!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe I was able to simplify the logic, please let me know if these changes address your feedback @gziolo, thanks.

() => {
if ( documentSettingsActive ) {
setSelectedTabName( documentSettingsName );
}
}, [ documentSettingsActive ]
);

useEffect(
() => {
if ( blockSettingsActive ) {
setSelectedTabName( blockSettingsName );
}
}, [ blockSettingsActive ]
);

return (
<SidebarHeader
className="edit-post-sidebar__panel-tabs"
closeLabel={ __( 'Close settings' ) }
>
{ /* Use a list so screen readers will announce how many tabs there are. */ }
<ul>
<li>
<button
onClick={ openDocumentSettings }
className={ `edit-post-sidebar__panel-tab ${ documentActiveClass }` }
aria-label={ documentAriaLabel }
data-label={ __( 'Document' ) }
>
{ __( 'Document' ) }
</button>
</li>
<li>
<button
onClick={ openBlockSettings }
className={ `edit-post-sidebar__panel-tab ${ blockActiveClass }` }
aria-label={ blockAriaLabel }
data-label={ blockLabel }
>
{ blockLabel }
</button>
</li>
</ul>
<TabPanel
tabs={ tabs }
onSelect={ onSelect }
controlledTabName={ selectedTabName }
>
{ () => { } }
Copy link

@ItsJonQ ItsJonQ Jul 23, 2019

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jffng Hallooo! This PR looks great! I was a little confused at this one (at first glance).

What do you think about adjusted the original TabPanel component's children() render to do something like...

children && children(selectedTab)

That way, you won't have to pass it in a children render function that returns null/empty.

P.S. I'm not sure what the current conventions are for handling this flow 😊. The flow I suggested feels little cleaner + less ambiguous

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @ItsJonQ! I agree. We could also set children's default value to noop.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aww yiss! noop it up! 🎉

</TabPanel>
</SidebarHeader>
);
};
Expand Down
Loading