Skip to content

Commit

Permalink
Extract chip component for better reusability (#5065)
Browse files Browse the repository at this point in the history
* Extract MultiAutoComplete.Item to separate Chip component for better reusability

* Add onClick handler to chip

* Only show delete icon when onDelete callback is set

* Fix phpstan error

* Add additional test for clickable Chip item
  • Loading branch information
danrot committed Feb 19, 2020
1 parent 72ecf79 commit 6a7a349
Show file tree
Hide file tree
Showing 15 changed files with 244 additions and 138 deletions.
2 changes: 2 additions & 0 deletions phpstan.neon
Expand Up @@ -27,3 +27,5 @@ parameters:
- %currentWorkingDirectory%/src/Sulu/Bundle/TestBundle/Resources/app/config/config.php
# The following should be removed at some point
- %currentWorkingDirectory%/*/Tests/*
ignoreErrors:
- /Class Imagine\\Vips\\Imagine not found./
56 changes: 56 additions & 0 deletions src/Sulu/Bundle/AdminBundle/Resources/js/components/Chip/Chip.js
@@ -0,0 +1,56 @@
// @flow
import React from 'react';
import classNames from 'classnames';
import Icon from '../../components/Icon';
import chipStyles from './chip.scss';

type Props<T> = {|
children: string,
disabled: boolean,
onClick?: (value: T) => void,
onDelete?: (value: T) => void,
value: T,
|};

export default class Chip<T> extends React.Component<Props<T>> {
static defaultProps = {
disabled: false,
};

handleClick = () => {
const {onClick, value} = this.props;

if (onClick) {
onClick(value);
}
};

handleDelete = () => {
const {onDelete, value} = this.props;

if (onDelete) {
onDelete(value);
}
};

render() {
const {children, disabled, onClick, onDelete} = this.props;

const chipClass = classNames(
chipStyles.chip,
{
[chipStyles.disabled]: disabled,
[chipStyles.clickable]: !!onClick,
}
);

return (
<button className={chipClass} onClick={this.handleClick}>
{children}
{!disabled && onDelete &&
<Icon className={chipStyles.icon} name="su-times" onClick={this.handleDelete} />
}
</button>
);
}
}
44 changes: 44 additions & 0 deletions src/Sulu/Bundle/AdminBundle/Resources/js/components/Chip/README.md
@@ -0,0 +1,44 @@
Chips are compact elements that represent an attribute in its used context. It can e.g. be used to display tags in a tag
selection or can indicate that a list is filtered.

```javascript
<div style={{backgroundColor: 'white', padding: '10px'}}>
<Chip>Tag 1</Chip>
</div>
```

Chips can also be displayed in a disabled state:

```javascript
<div style={{backgroundColor: 'white', padding: '10px'}}>
<Chip disabled={true}>Tag 2</Chip>
</div>
```

Chips can also be clicked, which will trigger the optional `onClick` callback:

```javascript
const handleClick = (value) => {
alert('Clicked the chip with the value ' + value);
};

<div style={{backgroundColor: 'white', padding: '10px'}}>
<Chip onClick={handleClick} value={7}>Click me!</Chip>
</div>
```

They also accept a `onDelete` callback, which will render an `Icon` the user can click:

```javascript
const handleClick = (value) => {
alert('Clicked the chip with the value ' + value);
};

const handleDelete = (value) => {
alert('Delete the chip with the value ' + value);
};

<div style={{backgroundColor: 'white', padding: '10px'}}>
<Chip onClick={handleClick} onDelete={handleDelete} value={9}>Remove me!</Chip>
</div>
```
30 changes: 30 additions & 0 deletions src/Sulu/Bundle/AdminBundle/Resources/js/components/Chip/chip.scss
@@ -0,0 +1,30 @@
@import '../../containers/Application/colors.scss';

$chipBackgroundColor: $wildSand;
$chipDisabledBackgroundColor: $silver;

.chip {
background-color: $chipBackgroundColor;
border: none;
border-radius: 3px;
display: inline-block;
font-size: 12px;
line-height: 18px;
height: 18px;
margin: 5px 10px 5px 0;
padding: 0 10px;
position: relative;
white-space: nowrap;

.icon {
margin-left: 10px;
}
}

.clickable {
cursor: pointer;
}

.disabled {
background-color: $chipDisabledBackgroundColor;
}
@@ -0,0 +1,4 @@
// @flow
import Chip from './Chip';

export default Chip;
@@ -0,0 +1,40 @@
// @flow
import React from 'react';
import {render, shallow} from 'enzyme';
import Chip from '../Chip';

test('Should render item with children', () => {
expect(render(<Chip value={{}}>Name</Chip>)).toMatchSnapshot();
});

test('Should render item with delete icon', () => {
expect(render(<Chip onDelete={jest.fn()} value={{}}>Name</Chip>)).toMatchSnapshot();
});

test('Should render item without delete icon in disabled state', () => {
expect(render(<Chip disabled={true} onDelete={jest.fn()} value={{}}>Name</Chip>)).toMatchSnapshot();
});

test('Should render item as clickable', () => {
expect(render(<Chip onClick={jest.fn()} value={{}}>Name</Chip>)).toMatchSnapshot();
});

test('Should call onClick callback when the button is clicked', () => {
const clickSpy = jest.fn();
const value = {name: 'Test'};
const item = shallow(<Chip onClick={clickSpy} onDelete={jest.fn()} value={value}>Test</Chip>);

item.find('button').simulate('click');

expect(clickSpy).toBeCalledWith(value);
});

test('Should call onDelete callback when the times icon is clicked', () => {
const deleteSpy = jest.fn();
const value = {name: 'Test'};
const item = shallow(<Chip onDelete={deleteSpy} value={value}>Test</Chip>);

item.find('Icon').simulate('click');

expect(deleteSpy).toBeCalledWith(value);
});
@@ -0,0 +1,39 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`Should render item as clickable 1`] = `
<button
class="chip clickable"
>
Name
</button>
`;

exports[`Should render item with children 1`] = `
<button
class="chip"
>
Name
</button>
`;

exports[`Should render item with delete icon 1`] = `
<button
class="chip"
>
Name
<span
aria-label="su-times"
class="su-times clickable icon"
role="button"
tabindex="0"
/>
</button>
`;

exports[`Should render item without delete icon in disabled state 1`] = `
<button
class="chip disabled"
>
Name
</button>
`;

This file was deleted.

Expand Up @@ -9,7 +9,7 @@ import classNames from 'classnames';
import Icon from '../Icon';
import Loader from '../Loader';
import AutoCompletePopover from '../AutoCompletePopover';
import Item from './Item';
import Chip from '../Chip';
import multiAutoCompleteStyles from './multiAutoComplete.scss';

type Props = {|
Expand Down Expand Up @@ -182,14 +182,14 @@ class MultiAutoComplete extends React.Component<Props> {
</div>
<div className={multiAutoCompleteStyles.items}>
{value.map((item) => (
<Item
<Chip
disabled={disabled}
key={item[idProperty]}
onDelete={this.handleDelete}
value={item}
>
{item[displayProperty]}
</Item>
</Chip>
))}
<input
className={inputClass}
Expand Down

This file was deleted.

This file was deleted.

Expand Up @@ -82,7 +82,7 @@ test('Render the MultiAutoComplete with open suggestions list', () => {
multiAutoComplete.instance().inputValue = 'test';
multiAutoComplete.update();

expect(multiAutoComplete.find('.item').text()).toEqual('Test');
expect(multiAutoComplete.find('.chip').text()).toEqual('Test');
expect(pretty(document.body ? document.body.innerHTML : '')).toMatchSnapshot();
});

Expand Down Expand Up @@ -143,7 +143,7 @@ test('Clicking on delete icon of a suggestion should call the onChange callback
/>
);

multiAutoComplete.find('Item').at(1).find('Icon').simulate('click');
multiAutoComplete.find('Chip').at(1).find('Icon').simulate('click');

expect(changeSpy).toHaveBeenCalledWith([value[0]]);
});
Expand Down

This file was deleted.

0 comments on commit 6a7a349

Please sign in to comment.