Skip to content

Commit

Permalink
Merge pull request #3281 from WordPress/try/tab-component
Browse files Browse the repository at this point in the history
Using NavigableContainer to make a TabPanel for Inserter
  • Loading branch information
ephox-mogran authored Nov 14, 2017
2 parents 0559625 + ac75a8a commit 4312160
Show file tree
Hide file tree
Showing 8 changed files with 501 additions and 228 deletions.
1 change: 1 addition & 0 deletions components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export { default as Popover } from './popover';
export { default as ResponsiveWrapper } from './responsive-wrapper';
export { default as SandBox } from './sandbox';
export { default as Spinner } from './spinner';
export { default as TabPanel } from './tab-panel';
export { default as Toolbar } from './toolbar';
export { default as Tooltip } from './tooltip';
export { Slot, Fill, Provider as SlotFillProvider } from './slot-fill';
Expand Down
2 changes: 1 addition & 1 deletion components/navigable-container/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { Component } from '@wordpress/element';
import { focus, keycodes } from '@wordpress/utils';

/**
* Module Constants
* Module constants
*/
const { UP, DOWN, LEFT, RIGHT, TAB } = keycodes;

Expand Down
100 changes: 100 additions & 0 deletions components/tab-panel/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
TabPanel
=======

TabPanel is a React component to render an ARIA-compliant TabPanel. It has two sections: a list of tabs, and the view to show when tabs are chosen. When the list of tabs gets focused, the active tab gets focus (the first tab if there isn't one already). Use the arrow keys to navigate between tabs AND select the newly focused tab at the same time.

TabPanel is a Function-as-Children component. The function takes `tabName` as an argument.

## Usage

Renders a TabPanel with each tab representing a paragraph with its title.

```jsx

import { TabPanel } from '@wordpress/components';

const onSelect = ( tabName ) => {
console.log( 'Selecting tab', tabName );
};

function MyTabPanel() {
return (
<TabPanel className="my-tab-panel"
activeClass="active-tab"
onSelect={ onSelect }
tabs={ [
{
name: 'tab1',
title: 'Tab 1',
className: 'tab-one',
},
{
name: 'tab2',
title: 'Tab 2',
className: 'tab-two',
},
] }>
{
( tabName ) => {
return <p>${ tabName }</p>;
}
}
</TabPanel>
)
}
```

## Props

The component accepts the following props:

### className

The class to give to the outer container for the TabPanel

- Type: `String`
- Required: No
- Default: ''

### orientation

The orientation of the tablist (`vertical` or `horizontal`)

- Type: `String`
- Required: No
- Default: `horizontal`

### onSelect

The function called when a tab has been selected. It is passed the `tabName` as an argument.

- Type: `Function`
- Required: No
- Default: `noop`

### tabs

A list of tabs where each tab is defined by an object with the following fields:

1. name: String. Defines the key for the tab
2. title: String. Defines the translated text for the tab
3. className: String. Defines the class to put on the tab.

- Type: Array
- Required: Yes

### activeClass

The class to add to the active tab

- Type: `String`
- Required: No
- Default: `is-active`

### children

A function which renders the tabviews given the selected tab. The function is passed a `tabName` as an argument.
The element to which the tooltip should anchor.

- Type: (`String`) => `Element`
- Required: Yes
99 changes: 99 additions & 0 deletions components/tab-panel/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/**
* External dependencies
*/
import { partial, noop } from 'lodash';

/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';

/**
* Internal dependencies
*/
import { default as withInstanceId } from '../higher-order/with-instance-id';
import { NavigableMenu } from '../navigable-container';

const TabButton = ( { tabId, onClick, children, selected, ...rest } ) => (
<button role="tab"
tabIndex={ selected ? null : -1 }
aria-selected={ selected }
id={ tabId }
onClick={ onClick }
{ ...rest }
>
{ children }
</button>
);

class TabPanel extends Component {
constructor() {
super( ...arguments );

this.handleClick = this.handleClick.bind( this );
this.onNavigate = this.onNavigate.bind( this );

this.state = {
selected: this.props.tabs.length > 0 ? this.props.tabs[ 0 ].name : null,
};
}

handleClick( tabKey ) {
const { onSelect = noop } = this.props;
this.setState( {
selected: tabKey,
} );
onSelect( tabKey );
}

onNavigate( childIndex, child ) {
child.click();
}

render() {
const { selected } = this.state;
const {
activeClass = 'is-active',
className,
instanceId,
orientation = 'horizontal',
tabs,
} = this.props;

const selectedTab = tabs.find( ( { name } ) => name === selected );
const selectedId = instanceId + '-' + selectedTab.name;

return (
<div>
<NavigableMenu
role="tablist"
orientation={ orientation }
onNavigate={ this.onNavigate }
className={ className }
>
{ tabs.map( ( tab ) => (
<TabButton className={ `${ tab.className } ${ tab.name === selected ? activeClass : '' }` }
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' }
>
{ this.props.children( selectedTab.name ) }
</div>
) }
</div>
);
}
}

export default withInstanceId( TabPanel );
91 changes: 91 additions & 0 deletions components/tab-panel/test/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/**
* External dependencies
*/
import { mount } from 'enzyme';

/**
* Internal dependencies
*/
import TabPanel from '../';

describe( 'TabPanel', () => {
describe( 'basic rendering', () => {
it( 'should render a tabpanel, and clicking should change tabs', () => {
const wrapper = mount(
<TabPanel className="test-panel"
activeClass="active-tab"
tabs={
[
{
name: 'alpha',
title: 'Alpha',
className: 'alpha',
},
{
name: 'beta',
title: 'Beta',
className: 'beta',
},
{
name: 'gamma',
title: 'Gamma',
className: 'gamma',
},
]
}
>
{
( tabName ) => {
return <p tabIndex="0" className={ tabName + '-view' }>{ tabName }</p>;
}
}
</TabPanel>
);

const alphaTab = wrapper.find( 'button.alpha' );
const betaTab = wrapper.find( 'button.beta' );
const gammaTab = wrapper.find( 'button.gamma' );

const getAlphaView = () => wrapper.find( 'p.alpha-view' );
const getBetaView = () => wrapper.find( 'p.beta-view' );
const getGammaView = () => wrapper.find( 'p.gamma-view' );

const getActiveTab = () => wrapper.find( 'button.active-tab' );
const getActiveView = () => wrapper.find( 'div[role="tabpanel"]' );

expect( getActiveTab().text() ).toBe( 'Alpha' );
expect( getAlphaView().length ).toBe( 1 );
expect( getBetaView().length ).toBe( 0 );
expect( getGammaView().length ).toBe( 0 );
expect( getActiveView().text() ).toBe( 'alpha' );

betaTab.simulate( 'click' );
expect( getActiveTab().text() ).toBe( 'Beta' );
expect( getAlphaView().length ).toBe( 0 );
expect( getBetaView().length ).toBe( 1 );
expect( getGammaView().length ).toBe( 0 );
expect( getActiveView().text() ).toBe( 'beta' );

betaTab.simulate( 'click' );
expect( getActiveTab().text() ).toBe( 'Beta' );
expect( getAlphaView().length ).toBe( 0 );
expect( getBetaView().length ).toBe( 1 );
expect( getGammaView().length ).toBe( 0 );
expect( getActiveView().text() ).toBe( 'beta' );

gammaTab.simulate( 'click' );
expect( getActiveTab().text() ).toBe( 'Gamma' );
expect( getAlphaView().length ).toBe( 0 );
expect( getBetaView().length ).toBe( 0 );
expect( getGammaView().length ).toBe( 1 );
expect( getActiveView().text() ).toBe( 'gamma' );

alphaTab.simulate( 'click' );
expect( getActiveTab().text() ).toBe( 'Alpha' );
expect( getAlphaView().length ).toBe( 1 );
expect( getBetaView().length ).toBe( 0 );
expect( getGammaView().length ).toBe( 0 );
expect( getActiveView().text() ).toBe( 'alpha' );
} );
} );
} );
88 changes: 88 additions & 0 deletions editor/components/inserter/group.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* External dependencies
*/
import { isEqual } from 'lodash';

/**
* WordPress dependencies
*/
import { Component } from '@wordpress/element';
import { NavigableMenu } from '@wordpress/components';
import { BlockIcon } from '@wordpress/blocks';

function deriveActiveBlocks( blocks ) {
return blocks.filter( ( block ) => ! block.disabled );
}

export default class InserterGroup extends Component {
constructor() {
super( ...arguments );

this.onNavigate = this.onNavigate.bind( this );

this.activeBlocks = deriveActiveBlocks( this.props.blockTypes );
this.state = {
current: this.activeBlocks.length > 0 ? this.activeBlocks[ 0 ].name : null,
};
}

componentWillReceiveProps( nextProps ) {
if ( ! isEqual( this.props.blockTypes, nextProps.blockTypes ) ) {
this.activeBlocks = deriveActiveBlocks( nextProps.blockTypes );
// Try and preserve any still valid selected state.
const current = this.activeBlocks.find( block => block.name === this.state.current );
if ( ! current ) {
this.setState( {
current: this.activeBlocks.length > 0 ? this.activeBlocks[ 0 ].name : null,
} );
}
}
}

renderItem( block ) {
const { current } = this.state;
const { selectBlock, bindReferenceNode } = this.props;
const { disabled } = block;
return (
<button
role="menuitem"
key={ block.name }
className="editor-inserter__block"
onClick={ selectBlock( block.name ) }
ref={ bindReferenceNode( block.name ) }
tabIndex={ current === block.name || disabled ? null : '-1' }
onMouseEnter={ ! disabled ? this.props.showInsertionPoint : null }
onMouseLeave={ ! disabled ? this.props.hideInsertionPoint : null }
disabled={ disabled }
>
<BlockIcon icon={ block.icon } />
{ block.title }
</button>
);
}

onNavigate( index ) {
const { activeBlocks } = this;
const dest = activeBlocks[ index ];
if ( dest ) {
this.setState( {
current: dest.name,
} );
}
}

render() {
const { labelledBy, blockTypes } = this.props;

return (
<NavigableMenu
className="editor-inserter__category-blocks"
orientation="vertical"
aria-labelledby={ labelledBy }
cycle={ false }
onNavigate={ this.onNavigate }>
{ blockTypes.map( this.renderItem, this ) }
</NavigableMenu>
);
}
}
Loading

0 comments on commit 4312160

Please sign in to comment.