-
Notifications
You must be signed in to change notification settings - Fork 4.2k
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #3281 from WordPress/try/tab-component
Using NavigableContainer to make a TabPanel for Inserter
- Loading branch information
Showing
8 changed files
with
501 additions
and
228 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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' ); | ||
} ); | ||
} ); | ||
} ); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
); | ||
} | ||
} |
Oops, something went wrong.