Skip to content
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

Merged
merged 2 commits into from
Oct 6, 2017
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
125 changes: 46 additions & 79 deletions blocks/color-palette/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand All @@ -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 };
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Super minor: { color } :)

Copy link
Contributor Author

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.

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={ () => (
Copy link
Member

Choose a reason for hiding this comment

The 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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The 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 renderToggle should be the children function because it's the one being displayed as a child and not in a portal.

The answer was not obvious to me, which one to choose so I preferred avoiding it completely and being consistent between renderToggle and renderContent

<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(
Expand Down
77 changes: 77 additions & 0 deletions components/dropdown/README.md
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
63 changes: 63 additions & 0 deletions components/dropdown/index.js
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 ) ) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we guard against falsy this.container?

Copy link
Contributor Author

@youknowriad youknowriad Oct 5, 2017

Choose a reason for hiding this comment

The 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;
53 changes: 53 additions & 0 deletions components/dropdown/test/index.js
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 );
} );
} );
1 change: 1 addition & 0 deletions components/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Loading