diff --git a/blocks/color-palette/index.js b/blocks/color-palette/index.js index 476f6110ffda78..fdb12c5d4ce03f 100644 --- a/blocks/color-palette/index.js +++ b/blocks/color-palette/index.js @@ -7,8 +7,7 @@ import { ChromePicker } from 'react-color'; /** * WordPress dependencies */ -import { Component } from '@wordpress/element'; -import { Popover } from '@wordpress/components'; +import { Dropdown } from '@wordpress/components'; import { __, sprintf } from '@wordpress/i18n'; /** @@ -17,94 +16,62 @@ import { __, sprintf } from '@wordpress/i18n'; import './style.scss'; import withEditorSettings from '../with-editor-settings'; -class ColorPalette extends Component { - constructor() { - super( ...arguments ); - this.state = { - opened: false, - }; - this.togglePicker = this.togglePicker.bind( this ); - this.closeOnClickOutside = this.closeOnClickOutside.bind( this ); - this.bindToggleNode = this.bindToggleNode.bind( this ); - } +function ColorPalette( { colors, value, onChange } ) { + return ( +
+ { colors.map( ( color ) => { + const style = { color: color }; + const className = classnames( 'blocks-color-palette__item', { 'is-active': value === color } ); - togglePicker() { - this.setState( ( state ) => ( { opened: ! state.opened } ) ); - } - - closeOnClickOutside( event ) { - const { opened } = this.state; - if ( opened && ! this.toggleNode.contains( event.target ) ) { - this.togglePicker(); - } - } - - bindToggleNode( node ) { - this.toggleNode = node; - } - - render() { - const { colors, value, onChange } = this.props; - return ( -
- { colors.map( ( color ) => { - const style = { color: color }; - const className = classnames( 'blocks-color-palette__item', { 'is-active': value === color } ); - - return ( -
-
- ); - } ) } + return ( +
+
+ ); + } ) } -
+ ( - - { - onChange( color.hex ); - this.togglePicker(); - } } - style={ { width: '100%' } } - disableAlpha - /> - -
+ ) } + renderContent={ () => ( + onChange( color.hex ) } + style={ { width: '100%' } } + disableAlpha + /> + ) } + /> -
- -
+
+
- ); - } +
+ ); } export default withEditorSettings( diff --git a/components/dropdown/README.md b/components/dropdown/README.md new file mode 100644 index 00000000000000..df196372b96346 --- /dev/null +++ b/components/dropdown/README.md @@ -0,0 +1,77 @@ +Popover +======= + +Dropdown is a React component to render a button that opens a floating content modal when clicked. +This components takes care of updating the state of the dropdown menu (opened/closed), handles closing the menu when clicking outside +and uses render props to render the button and the content. + +## Usage + + +```jsx +import { Dropdown } from '@wordpress/components'; + +function MyDropdownMenu() { + return ( + ( + + ) } + renderContent={ () => ( + This is the content of the popover. + ) } + > + ); +} +``` + +## Props + +The component accepts the following props. Props not included in this set will be applied to the element wrapping Popover content. + +### className + +className of the global container + +- Type: `String` +- Required: No + +### contentClassName + +If you want to target the dropdown menu for styling purposes, you need to provide a contentClassName because it's not being rendered as a children of the container nodee. + +- Type: `String` +- Required: No + +### position + +The direction in which the popover should open relative to its parent node. Specify y- and x-axis as a space-separated string. Supports `"top"`, `"bottom"` y axis, and `"left"`, `"center"`, `"right"` x axis. + +- Type: `String` +- Required: No +- Default: `"top center"` + +## renderToggle + +A callback invoked to render the Dropdown Toggle Button. + +- Type: `Function` +- Required: Yes + +The first argument of the callback is an object containing the following properties: + + - `isOpen`: whether the dropdown menu is opened or not + - `onToggle`: A function switching the dropdown menu's state from open to closed and vice versa + - `onClose`: A function that closes the menu if invoked + +## renderContent + +A callback invoked to render the content of the dropdown menu. Its first argument is the same as the `renderToggle` prop. + +- Type: `Function` +- Required: Yes diff --git a/components/dropdown/index.js b/components/dropdown/index.js new file mode 100644 index 00000000000000..8d932b393d15f2 --- /dev/null +++ b/components/dropdown/index.js @@ -0,0 +1,63 @@ +/** + * WordPress Dependeencies + */ +import { Component } from '@wordpress/element'; + +/** + * Internal Dependencies + */ +import Popover from '../popover'; + +class Dropdown extends Component { + constructor() { + super( ...arguments ); + this.toggle = this.toggle.bind( this ); + this.close = this.close.bind( this ); + this.clickOutside = this.clickOutside.bind( this ); + this.bindContainer = this.bindContainer.bind( this ); + this.state = { + isOpen: false, + }; + } + + bindContainer( ref ) { + this.container = ref; + } + + toggle() { + this.setState( ( state ) => ( { + isOpen: ! state.isOpen, + } ) ); + } + + clickOutside( event ) { + if ( ! this.container.contains( event.target ) ) { + this.close(); + } + } + + close() { + this.setState( { isOpen: false } ); + } + + render() { + const { isOpen } = this.state; + const { renderContent, renderToggle, position = 'bottom', className, contentClassName } = this.props; + const args = { isOpen, onToggle: this.toggle, onClose: this.close }; + return ( +
+ { renderToggle( args ) } + + { renderContent( args ) } + +
+ ); + } +} + +export default Dropdown; diff --git a/components/dropdown/test/index.js b/components/dropdown/test/index.js new file mode 100644 index 00000000000000..9c936342703bad --- /dev/null +++ b/components/dropdown/test/index.js @@ -0,0 +1,53 @@ +/** + * External dependencies + */ +import { mount } from 'enzyme'; + +/** + * Internal dependencies + */ +import Dropdown from '../'; + +describe( 'Dropdown', () => { + it( 'should toggle the dropdown properly', () => { + const wrapper = mount( ( + + ) } + renderContent={ () => 'content' } + /> ); + + const button = wrapper.find( 'button' ); + const popover = wrapper.find( 'Popover' ); + + expect( button ).toHaveLength( 1 ); + expect( popover.prop( 'isOpen' ) ).toBe( false ); + expect( button.prop( 'aria-expanded' ) ).toBe( false ); + button.simulate( 'click' ); + expect( popover.prop( 'isOpen' ) ).toBe( true ); + expect( button.prop( 'aria-expanded' ) ).toBe( true ); + } ); + + it( 'should close the dropdown when calling onClose', () => { + const wrapper = mount( [ + , + , + ] } + renderContent={ () => 'content' } + /> ); + + const openButton = wrapper.find( '.open' ); + const closeButton = wrapper.find( '.close' ); + const popover = wrapper.find( 'Popover' ); + expect( popover.prop( 'isOpen' ) ).toBe( false ); + openButton.simulate( 'click' ); + expect( popover.prop( 'isOpen' ) ).toBe( true ); + closeButton.simulate( 'click' ); + expect( popover.prop( 'isOpen' ) ).toBe( false ); + } ); +} ); diff --git a/components/index.js b/components/index.js index c58a683f234da1..1eb3dd2516e595 100644 --- a/components/index.js +++ b/components/index.js @@ -6,6 +6,7 @@ export { default as ClipboardButton } from './clipboard-button'; export { default as Dashicon } from './dashicon'; export { default as DropZone } from './drop-zone'; export { default as DropZoneProvider } from './drop-zone/provider'; +export { default as Dropdown } from './dropdown'; export { default as DropdownMenu } from './dropdown-menu'; export { default as ExternalLink } from './external-link'; export { default as FormFileUpload } from './form-file-upload'; diff --git a/editor/inserter/index.js b/editor/inserter/index.js index 6929b1e3d8ac3c..c67c8f3ca3bf4c 100644 --- a/editor/inserter/index.js +++ b/editor/inserter/index.js @@ -7,8 +7,7 @@ import { connect } from 'react-redux'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Component } from '@wordpress/element'; -import { Popover, IconButton } from '@wordpress/components'; +import { Dropdown, IconButton } from '@wordpress/components'; import { createBlock } from '@wordpress/blocks'; /** @@ -18,84 +17,37 @@ import InserterMenu from './menu'; import { getBlockInsertionPoint, getEditorMode } from '../selectors'; import { insertBlock, hideInsertionPoint } from '../actions'; -class Inserter extends Component { - constructor() { - super( ...arguments ); - - this.toggle = this.toggle.bind( this ); - this.close = this.close.bind( this ); - this.closeOnClickOutside = this.closeOnClickOutside.bind( this ); - this.bindNode = this.bindNode.bind( this ); - this.insertBlock = this.insertBlock.bind( this ); - - this.state = { - opened: false, - }; - } - - toggle() { - this.setState( ( state ) => ( { - opened: ! state.opened, - } ) ); - } - - close() { - this.setState( { - opened: false, - } ); - } - - closeOnClickOutside( event ) { - if ( ! this.node.contains( event.target ) ) { - this.close(); - } - } - - bindNode( node ) { - this.node = node; - } - - insertBlock( name ) { - const { - insertionPoint, - onInsertBlock, - } = this.props; - - onInsertBlock( - name, - insertionPoint - ); - - this.close(); - } - - render() { - const { opened } = this.state; - const { position, children } = this.props; - - return ( -
+function Inserter( { position, children, onInsertBlock, insertionPoint } ) { + return ( + ( { children } - - - -
- ); - } + ) } + renderContent={ ( { onClose } ) => { + const onInsert = ( name ) => { + onInsertBlock( + name, + insertionPoint + ); + + onClose(); + }; + + return ; + } } + /> + ); } export default connect( diff --git a/editor/sidebar/post-schedule/index.js b/editor/sidebar/post-schedule/index.js index 51b81ffad80cba..be659a250d6dea 100644 --- a/editor/sidebar/post-schedule/index.js +++ b/editor/sidebar/post-schedule/index.js @@ -10,9 +10,8 @@ import { flowRight } from 'lodash'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Component } from '@wordpress/element'; import { dateI18n, settings } from '@wordpress/date'; -import { PanelRow, Popover, withAPIData } from '@wordpress/components'; +import { PanelRow, Dropdown, withAPIData } from '@wordpress/components'; /** * Internal dependencies @@ -22,82 +21,62 @@ import PostScheduleClock from './clock'; import { getEditedPostAttribute } from '../../selectors'; import { editPost } from '../../actions'; -export class PostSchedule extends Component { - constructor() { - super( ...arguments ); - - this.toggleDialog = this.toggleDialog.bind( this ); - this.closeDialog = this.closeDialog.bind( this ); - - this.state = { - opened: false, - }; - } - - toggleDialog() { - this.setState( ( state ) => ( { opened: ! state.opened } ) ); - } - - closeDialog() { - this.setState( { opened: false } ); +export function PostSchedule( { date, onUpdateDate, user } ) { + if ( ! user.data || ! user.data.capabilities.publish_posts ) { + return null; } - render() { - const { date, onUpdateDate, user } = this.props; - - if ( ! user.data || ! user.data.capabilities.publish_posts ) { - return null; - } - - const momentDate = date ? moment( date ) : moment(); - const label = date - ? dateI18n( settings.formats.datetime, date ) - : __( 'Immediately' ); - const handleChange = ( newDate ) => { - onUpdateDate( newDate.format( 'YYYY-MM-DDTHH:mm:ss' ) ); - }; + const momentDate = date ? moment( date ) : moment(); + const label = date + ? dateI18n( settings.formats.datetime, date ) + : __( 'Immediately' ); + const handleChange = ( newDate ) => { + onUpdateDate( newDate.format( 'YYYY-MM-DDTHH:mm:ss' ) ); + }; // To know if the current timezone is a 12 hour time with look for "a" in the time format // We also make sure this a is not escaped by a "/" - const is12HourTime = /a(?!\\)/i.test( - settings.formats.time - .toLowerCase() // Test only the lower case a - .replace( /\\\\/g, '' ) // Replace "//" with empty strings - .split( '' ).reverse().join( '' ) // Reverse the string and test for "a" not followed by a slash - ); + const is12HourTime = /a(?!\\)/i.test( + settings.formats.time + .toLowerCase() // Test only the lower case a + .replace( /\\\\/g, '' ) // Replace "//" with empty strings + .split( '' ).reverse().join( '' ) // Reverse the string and test for "a" not followed by a slash + ); - return ( - - { __( 'Publish' ) } - - - ); - } + { label } + + ) } + renderContent={ () => ( [ + , + , + ] ) } + /> + + ); } const applyConnect = connect( diff --git a/editor/sidebar/post-visibility/index.js b/editor/sidebar/post-visibility/index.js index 21c61498263646..438a4808820575 100644 --- a/editor/sidebar/post-visibility/index.js +++ b/editor/sidebar/post-visibility/index.js @@ -9,7 +9,7 @@ import { find, flowRight } from 'lodash'; */ import { __ } from '@wordpress/i18n'; import { Component } from '@wordpress/element'; -import { PanelRow, Popover, withInstanceId, withAPIData } from '@wordpress/components'; +import { PanelRow, Dropdown, withInstanceId, withAPIData } from '@wordpress/components'; /** * Internal Dependencies @@ -25,42 +25,15 @@ export class PostVisibility extends Component { constructor( props ) { super( ...arguments ); - this.toggleDialog = this.toggleDialog.bind( this ); - this.stopPropagation = this.stopPropagation.bind( this ); - this.closeOnClickOutside = this.closeOnClickOutside.bind( this ); - this.close = this.close.bind( this ); this.setPublic = this.setPublic.bind( this ); this.setPrivate = this.setPrivate.bind( this ); this.setPasswordProtected = this.setPasswordProtected.bind( this ); - this.bindButtonNode = this.bindButtonNode.bind( this ); this.state = { - opened: false, hasPassword: !! props.password, }; } - toggleDialog() { - this.setState( ( state ) => ( { opened: ! state.opened } ) ); - } - - stopPropagation( event ) { - event.stopPropagation(); - } - - closeOnClickOutside( event ) { - if ( ! this.buttonNode.contains( event.target ) ) { - this.close(); - } - } - - close() { - const { opened } = this.state; - if ( opened ) { - this.toggleDialog(); - } - } - setPublic() { const { visibility, onUpdateVisibility, status } = this.props; @@ -87,10 +60,6 @@ export class PostVisibility extends Component { this.setState( { hasPassword: true } ); } - bindButtonNode( node ) { - this.buttonNode = node; - } - render() { const { status, visibility, password, onUpdateVisibility, instanceId, user } = this.props; const canEdit = user.data && user.data.capabilities.publish_posts; @@ -129,23 +98,21 @@ export class PostVisibility extends Component { { __( 'Visibility' ) } { ! canEdit && { getVisibilityLabel( visibility ) } } { canEdit && ( - + ) } + renderContent={ () => ( [ +
{ __( 'Post Visibility' ) } @@ -170,9 +137,9 @@ export class PostVisibility extends Component { {

{ info }

}
) ) } - - { this.state.hasPassword && -
+ , + this.state.hasPassword && ( +
- } - - + ), + ] ) } + /> ) } ); diff --git a/editor/sidebar/post-visibility/test/index.js b/editor/sidebar/post-visibility/test/index.js index f123d729ace526..6f3559f0c12004 100644 --- a/editor/sidebar/post-visibility/test/index.js +++ b/editor/sidebar/post-visibility/test/index.js @@ -23,11 +23,11 @@ describe( 'PostVisibility', () => { wrapper = shallow( ); - expect( wrapper.find( 'button' ) ).toHaveLength( 0 ); + expect( wrapper.find( 'Dropdown' ) ).toHaveLength( 0 ); } ); it( 'should render if the user has the correct capability', () => { const wrapper = shallow( ); - expect( wrapper.find( 'button' ) ).not.toHaveLength( 0 ); + expect( wrapper.find( 'Dropdown' ) ).not.toHaveLength( 0 ); } ); } ); diff --git a/editor/table-of-contents/index.js b/editor/table-of-contents/index.js index 7ee23a3055455c..c87dfce5eaffbc 100644 --- a/editor/table-of-contents/index.js +++ b/editor/table-of-contents/index.js @@ -8,8 +8,7 @@ import { filter } from 'lodash'; * WordPress dependencies */ import { __ } from '@wordpress/i18n'; -import { Dashicon, Popover } from '@wordpress/components'; -import { Component } from '@wordpress/element'; +import { Dashicon, Dropdown } from '@wordpress/components'; /** * Internal dependencies @@ -20,57 +19,47 @@ import WordCount from '../word-count'; import { getBlocks } from '../selectors'; import { selectBlock } from '../actions'; -class TableOfContents extends Component { - constructor() { - super( ...arguments ); - this.state = { - showPopover: false, - }; - } +function TableOfContents( { blocks } ) { + const headings = filter( blocks, ( block ) => block.name === 'core/heading' ); - render() { - const { blocks } = this.props; - const headings = filter( blocks, ( block ) => block.name === 'core/heading' ); - - return ( -
+ return ( + ( - this.setState( { showPopover: false } ) } - > -
-
- - { __( 'Word Count' ) } -
-
- { blocks.length } - { __( 'Blocks' ) } -
-
- { headings.length } - { __( 'Headings' ) } -
+ ) } + renderContent={ () => ( [ +
+
+ + { __( 'Word Count' ) }
- { headings.length > 0 && -
-
- { __( 'Table of Contents' ) } - -
- } - -
- ); - } +
+ { blocks.length } + { __( 'Blocks' ) } +
+
+ { headings.length } + { __( 'Headings' ) } +
+
, + headings.length > 0 && ( +
+
+ { __( 'Table of Contents' ) } + +
+ ), + ] ) } + /> + ); } export default connect(