Skip to content
This repository has been archived by the owner on Feb 27, 2020. It is now read-only.

Commit

Permalink
feat(combobox): clone carbon V9 ComboBox to support custom item eleme…
Browse files Browse the repository at this point in the history
…nt (#90)
  • Loading branch information
flannanl authored and loganmccaul committed Aug 14, 2019
1 parent f9311fe commit 20d9332
Show file tree
Hide file tree
Showing 4 changed files with 148 additions and 20 deletions.
39 changes: 36 additions & 3 deletions src/components/ComboBox/ComboBox-test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* Copyright IBM Corp. 2016, 2018
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import React from 'react';
import { mount } from 'enzyme';
import {
Expand All @@ -11,7 +18,11 @@ import {
import ComboBox from '../ComboBox';

const findInputNode = wrapper => wrapper.find('.bx--text-input');
const clearInput = wrapper => wrapper.instance().handleOnInputValueChange('');
const downshiftActions = {
setHighlightedIndex: jest.fn(),
};
const clearInput = wrapper =>
wrapper.instance().handleOnInputValueChange('', downshiftActions);

describe('ComboBox', () => {
let mockProps;
Expand Down Expand Up @@ -40,6 +51,28 @@ describe('ComboBox', () => {
assertMenuOpen(wrapper, mockProps);
});

it('should display custom div of an item', () => {
const itemToElement = item => {
return <div className="custom-div">{item.label}</div>;
};
const wrapper = mount(
<ComboBox {...mockProps} itemToElement={itemToElement} />
);
findInputNode(wrapper).simulate('click');

assertMenuOpen(wrapper, mockProps);

expect(
wrapper.containsAllMatchingElements([
<div className="custom-div">Item 0</div>,
<div className="custom-div">Item 1</div>,
<div className="custom-div">Item 2</div>,
<div className="custom-div">Item 3</div>,
<div className="custom-div">Item 4</div>,
])
).toBe(true);
});

it('should call `onChange` each time an item is selected', () => {
const wrapper = mount(<ComboBox {...mockProps} />);
expect(mockProps.onChange).not.toHaveBeenCalled();
Expand Down Expand Up @@ -132,10 +165,10 @@ describe('ComboBox', () => {
it('should set `inputValue` to an empty string if a falsey-y value is given', () => {
const wrapper = mount(<ComboBox {...mockProps} />);

wrapper.instance().handleOnInputValueChange('foo');
wrapper.instance().handleOnInputValueChange('foo', downshiftActions);
expect(wrapper.state('inputValue')).toBe('foo');

wrapper.instance().handleOnInputValueChange(null);
wrapper.instance().handleOnInputValueChange(null, downshiftActions);
expect(wrapper.state('inputValue')).toBe('');
});
});
Expand Down
120 changes: 103 additions & 17 deletions src/components/ComboBox/ComboBox.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
/**
* Copyright IBM Corp. 2016, 2018
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

import cx from 'classnames';
import Downshift from 'downshift';
import PropTypes from 'prop-types';
import React from 'react';
import { settings } from 'carbon-components';
import ListBox, { PropTypes as ListBoxPropTypes } from '../ListBox';

const { prefix } = settings;

const defaultItemToString = item => {
if (typeof item === 'string') {
return item;
Expand All @@ -12,11 +22,7 @@ const defaultItemToString = item => {
return item && item.label;
};

const defaultShouldFilterItem = ({ inputValue, item, itemToString }) =>
!inputValue ||
itemToString(item)
.toLowerCase()
.includes(inputValue.toLowerCase());
const defaultShouldFilterItem = () => true;

const getInputValue = (props, state) => {
if (props.initialSelectedItem) {
Expand All @@ -26,8 +32,30 @@ const getInputValue = (props, state) => {
return state.inputValue || '';
};

const findHighlightedIndex = ({ items, itemToString }, inputValue) => {
if (!inputValue) {
return -1;
}

const searchValue = inputValue.toLowerCase();

for (let i = 0; i < items.length; i++) {
const item = itemToString(items[i]).toLowerCase();
if (item.indexOf(searchValue) !== -1) {
return i;
}
}

return -1;
};

export default class ComboBox extends React.Component {
static propTypes = {
/**
* 'aria-label' of the ListBox component.
*/
ariaLabel: PropTypes.string,

/**
* An optional className to add to the container node
*/
Expand All @@ -41,7 +69,7 @@ export default class ComboBox extends React.Component {
/**
* Specify a custom `id` for the input
*/
id: PropTypes.string,
id: PropTypes.string.isRequired,

/**
* Allow users to pass in an arbitrary item or a string (in case their items are an array of strings)
Expand All @@ -65,6 +93,12 @@ export default class ComboBox extends React.Component {
*/
itemToString: PropTypes.func,

/**
* Optional function to render items as custom components instead of strings.
* Defaults to null and is overriden by a getter
*/
itemToElement: PropTypes.func,

/**
* `onChange` is a utility for this controlled component to communicate to a
* consuming component when a specific dropdown item is selected.
Expand Down Expand Up @@ -122,14 +156,18 @@ export default class ComboBox extends React.Component {
static defaultProps = {
disabled: false,
itemToString: defaultItemToString,
itemToElement: null,
shouldFilterItem: defaultShouldFilterItem,
type: 'default',
ariaLabel: 'ListBox input field',
ariaLabel: 'Choose an item',
light: false,
};

constructor(props) {
super(props);

this.textInput = React.createRef();

this.state = {
inputValue: getInputValue(props, {}),
};
Expand Down Expand Up @@ -160,8 +198,11 @@ export default class ComboBox extends React.Component {
event.stopPropagation();
};

handleOnInputValueChange = inputValue => {
handleOnInputValueChange = (inputValue, { setHighlightedIndex }) => {
const { onInputChange } = this.props;

setHighlightedIndex(findHighlightedIndex(this.props, inputValue));

this.setState(
() => ({
// Default to empty string if we have a false-y `inputValue`
Expand All @@ -175,13 +216,23 @@ export default class ComboBox extends React.Component {
);
};

onToggleClick = isOpen => event => {
if (event.target === this.textInput.current && isOpen) {
event.preventDownshiftDefault = true;
event.persist();
}
};

render() {
const {
className: containerClassName,
disabled,
id,
items,
itemToString,
itemToElement,
titleText,
helperText,
placeholder,
initialSelectedItem,
ariaLabel,
Expand All @@ -195,9 +246,23 @@ export default class ComboBox extends React.Component {
onInputChange, // eslint-disable-line no-unused-vars
...rest
} = this.props;
const className = cx('bx--combo-box', containerClassName);

return (
const className = cx(`${prefix}--combo-box`, containerClassName);
const titleClasses = cx(`${prefix}--label`, {
[`${prefix}--label--disabled`]: disabled,
});
const title = titleText ? (
<label htmlFor={id} className={titleClasses}>
{titleText}
</label>
) : null;
const helperClasses = cx(`${prefix}--form__helper-text`, {
[`${prefix}--form__helper-text--disabled`]: disabled,
});
const helper = helperText ? (
<div className={helperClasses}>{helperText}</div>
) : null;
const wrapperClasses = cx(`${prefix}--list-box__wrapper`);
const input = (
<Downshift
onChange={this.handleOnChange}
onInputValueChange={this.handleOnInputValueChange}
Expand All @@ -220,12 +285,21 @@ export default class ComboBox extends React.Component {
disabled={disabled}
invalid={invalid}
invalidText={invalidText}
isOpen={isOpen}
light={light}
{...getRootProps({ refKey: 'innerRef' })}>
<ListBox.Field {...getButtonProps({ disabled })}>
<ListBox.Field
id={id}
{...getButtonProps({
disabled,
onClick: this.onToggleClick(isOpen),
})}>
<input
className="bx--text-input"
className={`${prefix}--text-input`}
aria-label={ariaLabel}
aria-controls={`${id}__menu`}
aria-autocomplete="list"
ref={this.textInput}
{...rest}
{...getInputProps({
disabled,
Expand All @@ -234,7 +308,7 @@ export default class ComboBox extends React.Component {
onKeyDown: this.handleOnInputKeyDown,
})}
/>
{inputValue && isOpen && (
{inputValue && (
<ListBox.Selection
clearSelection={clearSelection}
translateWithId={translateWithId}
Expand All @@ -246,15 +320,19 @@ export default class ComboBox extends React.Component {
/>
</ListBox.Field>
{isOpen && (
<ListBox.Menu>
<ListBox.Menu aria-label={ariaLabel} id={id}>
{this.filterItems(items, itemToString, inputValue).map(
(item, index) => (
<ListBox.MenuItem
key={itemToString(item)}
isActive={selectedItem === item}
isHighlighted={highlightedIndex === index}
isHighlighted={
highlightedIndex === index ||
(selectedItem && selectedItem.id === item.id) ||
false
}
{...getItemProps({ item, index })}>
{itemToString(item)}
{itemToElement ? itemToElement(item) : itemToString(item)}
</ListBox.MenuItem>
)
)}
Expand All @@ -264,5 +342,13 @@ export default class ComboBox extends React.Component {
)}
</Downshift>
);

return (
<React.Fragment>
{title}
{helper}
{input}
</React.Fragment>
);
}
}
7 changes: 7 additions & 0 deletions src/components/ComboBox/tools/filter.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
/**
* Copyright IBM Corp. 2016, 2018
*
* This source code is licensed under the Apache-2.0 license found in the
* LICENSE file in the root directory of this source tree.
*/

export const defaultFilterItems = (items, { itemToString, inputValue }) =>
items.filter(item => {
if (!inputValue) {
Expand Down
2 changes: 2 additions & 0 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export CardStatus from './components/CardStatus';
export * from './components/CloudHeader';
export { default as CloudHeader } from './components/CloudHeader';

export ComboBox from './components/ComboBox';

export DetailPageHeader from './components/DetailPageHeader';

export InteriorLeftNav from './components/InteriorLeftNav';
Expand Down

0 comments on commit 20d9332

Please sign in to comment.