-
Notifications
You must be signed in to change notification settings - Fork 4.3k
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
Components: Extract a generic dropdown component #2888
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 ( | ||
<div className="blocks-color-palette"> | ||
{ 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 ( | ||
<div className="blocks-color-palette"> | ||
{ colors.map( ( color ) => { | ||
const style = { color: color }; | ||
const className = classnames( 'blocks-color-palette__item', { 'is-active': value === color } ); | ||
|
||
return ( | ||
<div key={ color } className="blocks-color-palette__item-wrapper"> | ||
<button | ||
type="button" | ||
className={ className } | ||
style={ style } | ||
onClick={ () => onChange( value === color ? undefined : color ) } | ||
aria-label={ sprintf( __( 'Color: %s' ), color ) } | ||
aria-pressed={ value === color } | ||
/> | ||
</div> | ||
); | ||
} ) } | ||
return ( | ||
<div key={ color } className="blocks-color-palette__item-wrapper"> | ||
<button | ||
type="button" | ||
className={ className } | ||
style={ style } | ||
onClick={ () => onChange( value === color ? undefined : color ) } | ||
aria-label={ sprintf( __( 'Color: %s' ), color ) } | ||
aria-pressed={ value === color } | ||
/> | ||
</div> | ||
); | ||
} ) } | ||
|
||
<div className="blocks-color-palette__item-wrapper blocks-color-palette__custom-color"> | ||
<Dropdown | ||
className="blocks-color-palette__item-wrapper blocks-color-palette__custom-color" | ||
contentClassName="blocks-color-palette__picker " | ||
renderToggle={ ( { isOpen, onToggle } ) => ( | ||
<button | ||
type="button" | ||
aria-expanded={ this.state.opened } | ||
aria-expanded={ isOpen } | ||
className="blocks-color-palette__item" | ||
onClick={ this.togglePicker } | ||
ref={ this.bindToggleNode } | ||
onClick={ onToggle } | ||
aria-label={ __( 'Custom color picker' ) } | ||
> | ||
<span className="blocks-color-palette__custom-color-gradient" /> | ||
</button> | ||
<Popover | ||
isOpen={ this.state.opened } | ||
onClickOutside={ this.closeOnClickOutside } | ||
className="blocks-color-palette__picker" | ||
> | ||
<ChromePicker | ||
color={ value } | ||
onChangeComplete={ ( color ) => { | ||
onChange( color.hex ); | ||
this.togglePicker(); | ||
} } | ||
style={ { width: '100%' } } | ||
disableAlpha | ||
/> | ||
</Popover> | ||
</div> | ||
) } | ||
renderContent={ () => ( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Did you consider just passing this as children? Even if it were a function callback? There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Yes, I did, it was my first option, but then I hesitated whether the The answer was not obvious to me, which one to choose so I preferred avoiding it completely and being consistent between |
||
<ChromePicker | ||
color={ value } | ||
onChangeComplete={ ( color ) => onChange( color.hex ) } | ||
style={ { width: '100%' } } | ||
disableAlpha | ||
/> | ||
) } | ||
/> | ||
|
||
<div className="blocks-color-palette__item-wrapper blocks-color-palette__clear-color"> | ||
<button | ||
className="blocks-color-palette__item" | ||
onClick={ () => onChange( undefined ) } | ||
aria-label={ __( 'Remove color' ) } | ||
> | ||
<span className="blocks-color-palette__clear-color-line" /> | ||
</button> | ||
</div> | ||
<div className="blocks-color-palette__item-wrapper blocks-color-palette__clear-color"> | ||
<button | ||
className="blocks-color-palette__item" | ||
onClick={ () => onChange( undefined ) } | ||
aria-label={ __( 'Remove color' ) } | ||
> | ||
<span className="blocks-color-palette__clear-color-line" /> | ||
</button> | ||
</div> | ||
); | ||
} | ||
</div> | ||
); | ||
} | ||
|
||
export default withEditorSettings( | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ( | ||
<Dropdown | ||
className="my-container-class-name" | ||
contentClassName="my-popover-content-classname" | ||
position="bottom right" | ||
renderToggle={ ( { isOpen, onToggle } ) => ( | ||
<button onClick={ onToggle } aria-expanded={ isOpen }> | ||
Toggle Popover! | ||
</button> | ||
) } | ||
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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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 ) ) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should we guard against falsy There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. contains is inclusive :) |
||
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 ( | ||
<div className={ className } ref={ this.bindContainer }> | ||
{ renderToggle( args ) } | ||
<Popover | ||
className={ contentClassName } | ||
isOpen={ isOpen } | ||
position={ position } | ||
onClickOutside={ this.clickOutside } | ||
> | ||
{ renderContent( args ) } | ||
</Popover> | ||
</div> | ||
); | ||
} | ||
} | ||
|
||
export default Dropdown; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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( <Dropdown | ||
className="container" | ||
contentClassName="content" | ||
renderToggle={ ( { isOpen, onToggle } ) => ( | ||
<button aria-expanded={ isOpen } onClick={ onToggle }>Toggleee</button> | ||
) } | ||
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( <Dropdown | ||
className="container" | ||
contentClassName="content" | ||
renderToggle={ ( { isOpen, onToggle, onClose } ) => [ | ||
<button key="open" className="open" aria-expanded={ isOpen } onClick={ onToggle }>Toggleee</button>, | ||
<button key="close" className="close" onClick={ onClose } >closee</button>, | ||
] } | ||
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 ); | ||
} ); | ||
} ); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Super minor:
{ color }
:)There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh forgot to update this before the merge.