diff --git a/components/index.js b/components/index.js index dabaab6b78688..94b7b5ead1da8 100644 --- a/components/index.js +++ b/components/index.js @@ -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'; diff --git a/components/navigable-container/index.js b/components/navigable-container/index.js index 7c31817656f9a..2839f2d9870ea 100644 --- a/components/navigable-container/index.js +++ b/components/navigable-container/index.js @@ -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; diff --git a/components/tab-panel/README.md b/components/tab-panel/README.md new file mode 100644 index 0000000000000..a66388836e7e4 --- /dev/null +++ b/components/tab-panel/README.md @@ -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 ( + + { + ( tabName ) => { + return

${ tabName }

; + } + } +
+ ) +} +``` + +## 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 diff --git a/components/tab-panel/index.js b/components/tab-panel/index.js new file mode 100644 index 0000000000000..9fba6dede040b --- /dev/null +++ b/components/tab-panel/index.js @@ -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 } ) => ( + +); + +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 ( +
+ + { tabs.map( ( tab ) => ( + + { tab.title } + + ) ) } + + { selectedTab && ( +
+ { this.props.children( selectedTab.name ) } +
+ ) } +
+ ); + } +} + +export default withInstanceId( TabPanel ); diff --git a/components/tab-panel/test/index.js b/components/tab-panel/test/index.js new file mode 100644 index 0000000000000..7e287628aae64 --- /dev/null +++ b/components/tab-panel/test/index.js @@ -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( + + { + ( tabName ) => { + return

{ tabName }

; + } + } +
+ ); + + 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' ); + } ); + } ); +} ); diff --git a/editor/components/inserter/group.js b/editor/components/inserter/group.js new file mode 100644 index 0000000000000..ec0880fd63738 --- /dev/null +++ b/editor/components/inserter/group.js @@ -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 ( + + ); + } + + onNavigate( index ) { + const { activeBlocks } = this; + const dest = activeBlocks[ index ]; + if ( dest ) { + this.setState( { + current: dest.name, + } ); + } + } + + render() { + const { labelledBy, blockTypes } = this.props; + + return ( + + { blockTypes.map( this.renderItem, this ) } + + ); + } +} diff --git a/editor/components/inserter/menu.js b/editor/components/inserter/menu.js index d6fb8328de174..005944dde38b6 100644 --- a/editor/components/inserter/menu.js +++ b/editor/components/inserter/menu.js @@ -1,7 +1,17 @@ /** * External dependencies */ -import { flow, groupBy, sortBy, findIndex, filter, find, some } from 'lodash'; +import { + filter, + find, + findIndex, + flow, + groupBy, + includes, + pick, + some, + sortBy, +} from 'lodash'; import { connect } from 'react-redux'; /** @@ -9,18 +19,23 @@ import { connect } from 'react-redux'; */ import { __, _n, sprintf } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; -import { withInstanceId, withSpokenMessages } from '@wordpress/components'; +import { + TabPanel, + TabbableContainer, + withInstanceId, + withSpokenMessages, +} from '@wordpress/components'; +import { getCategories, getBlockTypes } from '@wordpress/blocks'; import { keycodes } from '@wordpress/utils'; -import { getCategories, getBlockTypes, BlockIcon } from '@wordpress/blocks'; /** * Internal dependencies */ import './style.scss'; + import { getBlocks, getRecentlyUsedBlocks } from '../../selectors'; import { showInsertionPoint, hideInsertionPoint } from '../../actions'; - -const { TAB, LEFT, UP, RIGHT, DOWN } = keycodes; +import { default as InserterGroup } from './group'; export const searchBlocks = ( blocks, searchTerm ) => { const normalizedSearchTerm = searchTerm.toLowerCase().trim(); @@ -31,43 +46,40 @@ export const searchBlocks = ( blocks, searchTerm ) => { ); }; +/** + * Module constants + */ +const ARROWS = pick( keycodes, [ 'UP', 'DOWN', 'LEFT', 'RIGHT' ] ); + export class InserterMenu extends Component { constructor() { super( ...arguments ); this.nodes = {}; this.state = { filterValue: '', - currentFocus: 'search', tab: 'recent', }; this.filter = this.filter.bind( this ); - this.setSearchFocus = this.setSearchFocus.bind( this ); - this.onKeyDown = this.onKeyDown.bind( this ); this.searchBlocks = this.searchBlocks.bind( this ); - this.getBlocksForCurrentTab = this.getBlocksForCurrentTab.bind( this ); + this.getBlocksForTab = this.getBlocksForTab.bind( this ); this.sortBlocks = this.sortBlocks.bind( this ); + this.bindReferenceNode = this.bindReferenceNode.bind( this ); + this.selectBlock = this.selectBlock.bind( this ); this.tabScrollTop = { recent: 0, blocks: 0, embeds: 0 }; - } - - componentDidMount() { - document.addEventListener( 'keydown', this.onKeyDown, true ); - } - - componentWillUnmount() { - document.removeEventListener( 'keydown', this.onKeyDown, true ); + this.switchTab = this.switchTab.bind( this ); } componentDidUpdate( prevProps, prevState ) { const searchResults = this.searchBlocks( this.getBlockTypes() ); // Announce the blocks search results to screen readers. - if ( !! searchResults.length ) { + if ( this.state.filterValue && !! searchResults.length ) { this.props.debouncedSpeak( sprintf( _n( '%d result found', '%d results found', searchResults.length ), searchResults.length ), 'assertive' ); - } else { + } else if ( this.state.filterValue ) { this.props.debouncedSpeak( __( 'No results.' ), 'assertive' ); } @@ -95,7 +107,6 @@ export class InserterMenu extends Component { this.props.onSelect( name ); this.setState( { filterValue: '', - currentFocus: null, } ); }; } @@ -109,12 +120,12 @@ export class InserterMenu extends Component { return searchBlocks( blockTypes, this.state.filterValue ); } - getBlocksForCurrentTab() { + getBlocksForTab( tab ) { // if we're searching, use everything, otherwise just get the blocks visible in this tab if ( this.state.filterValue ) { return this.getBlockTypes(); } - switch ( this.state.tab ) { + switch ( tab ) { case 'recent': return this.props.recentlyUsedBlocks; case 'blocks': @@ -148,180 +159,23 @@ export class InserterMenu extends Component { )( blockTypes ); } - findByIncrement( blockTypes, increment = 1 ) { - const currentIndex = findIndex( blockTypes, ( blockType ) => this.state.currentFocus === blockType.name ); - const highestIndex = blockTypes.length - 1; - const lowestIndex = 0; - - let nextIndex = currentIndex; - let blockType; - do { - nextIndex += increment; - // Return the name of the next block type. - blockType = blockTypes[ nextIndex ]; - if ( blockType && ! this.isDisabledBlock( blockType ) ) { - return blockType.name; - } - } while ( blockType ); - - if ( nextIndex > highestIndex ) { - return 'search'; - } - - if ( nextIndex < lowestIndex ) { - return 'search'; - } - } - - findNext( blockTypes ) { - /** - * null is the initial state value and triggers start at beginning. - */ - if ( null === this.state.currentFocus ) { - return blockTypes[ 0 ].name; - } - - return this.findByIncrement( blockTypes, 1 ); - } - - findPrevious( blockTypes ) { - /** - * null is the initial state value and triggers start at beginning. - */ - if ( null === this.state.currentFocus ) { - return blockTypes[ 0 ].name; - } - - return this.findByIncrement( blockTypes, -1 ); - } - - focusNext() { - const sortedByCategory = flow( - this.searchBlocks, - this.sortBlocks, - )( this.getBlocksForCurrentTab() ); - - // If the block list is empty return early. - if ( ! sortedByCategory.length ) { - return; - } - - const nextBlock = this.findNext( sortedByCategory ); - this.changeMenuSelection( nextBlock ); - } - - focusPrevious() { - const sortedByCategory = flow( - this.searchBlocks, - this.sortBlocks, - )( this.getBlocksForCurrentTab() ); - - // If the block list is empty return early. - if ( ! sortedByCategory.length ) { - return; - } - - const nextBlock = this.findPrevious( sortedByCategory ); - this.changeMenuSelection( nextBlock ); - } - - onKeyDown( keydown ) { - switch ( keydown.keyCode ) { - case TAB: - if ( keydown.shiftKey ) { - // Previous. - keydown.preventDefault(); - this.focusPrevious( this ); - break; - } - // Next. - keydown.preventDefault(); - this.focusNext( this ); - break; - - case LEFT: - if ( this.state.currentFocus === 'search' ) { - return; - } - this.focusPrevious( this ); - break; - - case UP: - keydown.preventDefault(); - this.focusPrevious( this ); - break; - - case RIGHT: - if ( this.state.currentFocus === 'search' ) { - return; - } - this.focusNext( this ); - break; - - case DOWN: - keydown.preventDefault(); - this.focusNext( this ); - break; - - default: - return; - } - - // Since unhandled key will return in the default case, we can assume - // having reached this point implies that the key is handled. - keydown.stopImmediatePropagation(); - } - - changeMenuSelection( refName ) { - this.setState( { - currentFocus: refName, - } ); - - // Focus the DOM node. - this.nodes[ refName ].focus(); - } - - setSearchFocus() { - this.changeMenuSelection( 'search' ); - } - - getBlockItem( block ) { - const disabled = this.isDisabledBlock( block ); - return ( - - ); - } - - renderBlocks( blocks, separatorSlug ) { + renderBlocks( blockTypes, separatorSlug ) { const { instanceId } = this.props; const labelledBy = separatorSlug === undefined ? null : `editor-inserter__separator-${ separatorSlug }-${ instanceId }`; + const blockTypesInfo = blockTypes.map( ( blockType ) => ( + { ...blockType, disabled: this.isDisabledBlock( blockType ) } + ) ); return ( -
- { blocks.map( ( block ) => this.getBlockItem( block ) ) } -
+ ); } - renderCategory( category, blocks ) { + renderCategory( category, blockTypes ) { const { instanceId } = this.props; - return blocks && ( + return blockTypes && (
{ category.title }
- { this.renderBlocks( blocks, category.slug ) } + { this.renderBlocks( blockTypes, category.slug ) }
); } @@ -347,15 +201,39 @@ export class InserterMenu extends Component { this.setState( { tab } ); } + renderTabView( tab, visibleBlocks ) { + switch ( tab ) { + case 'recent': + return this.renderBlocks( this.props.recentlyUsedBlocks, undefined ); + + case 'embed': + return this.renderBlocks( visibleBlocks.embed, undefined ); + + default: + return this.renderCategories( visibleBlocks, undefined ); + } + } + + interceptArrows( event ) { + if ( includes( ARROWS, event.keyCode ) ) { + // Prevent cases of focus being unexpectedly stolen up in the tree, + // notably when using VisualEditorSiblingInserter, where focus is + // moved to sibling blocks. + // + // We don't need to stop the native event, which has its uses, e.g. + // allowing window scrolling. + event.stopPropagation(); + } + } + render() { const { instanceId } = this.props; - const visibleBlocksByCategory = this.getVisibleBlocksByCategory( this.getBlocksForCurrentTab() ); const isSearching = this.state.filterValue; - const isShowingEmbeds = ! isSearching && 'embeds' === this.state.tab; - const isShowingRecent = ! isSearching && 'recent' === this.state.tab; return ( -
+ @@ -365,38 +243,50 @@ export class InserterMenu extends Component { placeholder={ __( 'Search for a block' ) } className="editor-inserter__search" onChange={ this.filter } - onClick={ this.setSearchFocus } ref={ this.bindReferenceNode( 'search' ) } /> { ! isSearching && -
- - - + + { + ( tabKey ) => { + const blocksForTab = this.getBlocksForTab( tabKey ); + const visibleBlocks = this.getVisibleBlocksByCategory( blocksForTab ); + + return ( +
this.tabContainer = ref } + className="editor-inserter__content"> + { this.renderTabView( tabKey, visibleBlocks ) } +
+ ); + } + } +
+ } + { isSearching && +
+ { this.renderCategories( this.getVisibleBlocksByCategory( getBlockTypes() ) ) }
} -
this.tabContainer = ref }> - { isShowingRecent && this.renderBlocks( this.props.recentlyUsedBlocks ) } - { isShowingEmbeds && this.renderBlocks( visibleBlocksByCategory.embed ) } - { ! isShowingRecent && ! isShowingEmbeds && this.renderCategories( visibleBlocksByCategory ) } -
-
+
); } } diff --git a/editor/components/inserter/test/menu.js b/editor/components/inserter/test/menu.js index 9c897f165107e..856e0ba4f6495 100644 --- a/editor/components/inserter/test/menu.js +++ b/editor/components/inserter/test/menu.js @@ -65,6 +65,10 @@ const textEmbedBlock = { }; describe( 'InserterMenu', () => { + // NOTE: Due to https://github.com/airbnb/enzyme/issues/1174, some of the selectors passed through to + // wrapper.find have had to be strengthened (and the filterWhere strengthened also), otherwise two + // results would be returned even though only one was in the DOM. + const unregisterAllBlocks = () => { getBlockTypes().forEach( ( block ) => { unregisterBlockType( block.name ); @@ -96,7 +100,7 @@ describe( 'InserterMenu', () => { /> ); - const activeCategory = wrapper.find( '.editor-inserter__tab .is-active' ); + const activeCategory = wrapper.find( '.editor-inserter__tab button.is-active' ); expect( activeCategory.text() ).toBe( 'Recent' ); const visibleBlocks = wrapper.find( '.editor-inserter__block' ); @@ -131,10 +135,10 @@ describe( 'InserterMenu', () => { /> ); const embedTab = wrapper.find( '.editor-inserter__tab' ) - .filterWhere( ( node ) => node.text() === 'Embeds' ); + .filterWhere( ( node ) => node.text() === 'Embeds' && node.name() === 'button' ); embedTab.simulate( 'click' ); - const activeCategory = wrapper.find( '.editor-inserter__tab .is-active' ); + const activeCategory = wrapper.find( '.editor-inserter__tab button.is-active' ); expect( activeCategory.text() ).toBe( 'Embeds' ); const visibleBlocks = wrapper.find( '.editor-inserter__block' ); @@ -154,10 +158,10 @@ describe( 'InserterMenu', () => { /> ); const blocksTab = wrapper.find( '.editor-inserter__tab' ) - .filterWhere( ( node ) => node.text() === 'Blocks' ); + .filterWhere( ( node ) => node.text() === 'Blocks' && node.name() === 'button' ); blocksTab.simulate( 'click' ); - const activeCategory = wrapper.find( '.editor-inserter__tab .is-active' ); + const activeCategory = wrapper.find( '.editor-inserter__tab button.is-active' ); expect( activeCategory.text() ).toBe( 'Blocks' ); const visibleBlocks = wrapper.find( '.editor-inserter__block' ); @@ -179,7 +183,7 @@ describe( 'InserterMenu', () => { /> ); const blocksTab = wrapper.find( '.editor-inserter__tab' ) - .filterWhere( ( node ) => node.text() === 'Blocks' ); + .filterWhere( ( node ) => node.text() === 'Blocks' && node.name() === 'button' ); blocksTab.simulate( 'click' ); wrapper.update();