Skip to content

Commit

Permalink
#1 add SearchSelect and SeachSelectOptions components
Browse files Browse the repository at this point in the history
  • Loading branch information
jscottsmith committed Apr 16, 2019
1 parent 5ada87c commit e8001b2
Show file tree
Hide file tree
Showing 7 changed files with 326 additions and 5 deletions.
1 change: 1 addition & 0 deletions _stories/molecules/SearchSelect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

46 changes: 46 additions & 0 deletions _stories/molecules/SearchSelect/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react';
import { text, boolean, number } from '@storybook/addon-knobs';
import { optionalSelect } from '../../../components/utils/optionalSelect';
import { action } from '@storybook/addon-actions';
import LoadingDots from '../../../components/atoms/LoadingDots';

import readme from './README.md';
import SearchSelect from '../../../components/molecules/SearchSelect';

const options = [
{ id: 1, title: 'Lorem ipsum dolor sit amet' },
{ id: 2, title: 'Consectetur adipiscing elit ' },
{ id: 3, title: 'Sed do eiusmod tempor incididunt ut' },
{ id: 4, title: 'Labore et dolore magna aliqua' },
{ id: 5, title: 'Ut enim ad minim veniam' },
{ id: 6, title: 'Quis nostrud exercitation ullamco' },
{ id: 7, title: 'Laboris nisi ut aliquip ex ea commodo' },
{ id: 8, title: 'Consequat Duis' },
{ id: 9, title: 'Aute irure dolor in reprehenderit ' },
{ id: 10, title: 'In voluptate velit esse cillum dolore' },
{ id: 11, title: 'Eu fugiat nulla pariatur' },
{ id: 12, title: 'Deserunt mollit anim id est laborum' }
];

const sizeOptions = {
xs: 'xs',
sm: 'sm',
md: 'md'
};

const component = () => (
<SearchSelect
placeholder={text('Placeholder', 'Start typing...')}
options={options}
onChange={action('ON_CHANGE')}
onSelect={action('ON_SELECT')}
size={optionalSelect('Size', sizeOptions, '')}
isLoading={boolean('isLoading', false)}
debounceTime={number('debounceTime', 300)}
renderLoader={() => (
<LoadingDots size="sm" style={{ left: 'auto', right: '3em', top: '45%' }} />
)}
/>
);

export default [readme, component];
2 changes: 2 additions & 0 deletions _stories/molecules/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import Modal from './Modal/';
import Pagination from './Pagination/';
import SearchMultiSelect from './SearchMultiSelect/';
import Snackbar from './Snackbar/';
import SearchSelect from './SearchSelect/';
import Table from './Table/';
import Toggle from './Toggle/';
import Well from './Well/';
Expand All @@ -40,6 +41,7 @@ stories
.add('Modal', withReadme(...Modal))
.add('MultiSelect', withReadme(...MultiSelect))
.add('Pagination', withReadme(...Pagination))
.add('SearchSelect', withReadme(...SearchSelect))
.add('SearchMultiSelect', withReadme(...SearchMultiSelect))
.add('Snackbar', withReadme(...Snackbar))
.add('Table', withReadme(...Table))
Expand Down
8 changes: 3 additions & 5 deletions components/atoms/LoadingDots.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,14 @@ import cx from 'classnames';

const LoadingDots = ({ whiteDots, size, className, style }) => {
const baseClass = 'gds-loading__dot';
const dotClasses = cx(baseClass, {
const dotClasses = cx(baseClass, className, {
[`${baseClass}--${size}`]: size,
[`${baseClass}--white`]: whiteDots
});

return (
<div style={style} className={className}>
<div className="gds-loading">
<div className={dotClasses} />
</div>
<div style={style} className="gds-loading">
<div className={dotClasses} />
</div>
);
};
Expand Down
172 changes: 172 additions & 0 deletions components/molecules/SearchSelect.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import React, { Component } from 'react';
import cx from 'classnames';
import PropTypes from 'prop-types';
import SearchSelectOptions from './SearchSelectOptions';
import debounce from '../utils/debounce';

class SearchSelect extends Component {
static propTypes = {
debounceTime: PropTypes.number,
isFocused: PropTypes.bool,
isLoading: PropTypes.bool,
onChange: PropTypes.func,
onSelect: PropTypes.func.isRequired,
options: PropTypes.array,
placeholder: PropTypes.string,
renderLoader: PropTypes.bool,
size: PropTypes.oneOf(['xs', 'sm', 'md'])
};

static defaultProps = {
size: 'sm',
placeholder: 'Search',
options: [],
isFocused: false,
isLoading: false,
debounceTime: 300
};

state = {
isOpen: false,
searchTerm: ''
};

componentWillUnmount() {
this._removeListeners();
}

_mapContainerRef = ref => (this._container = ref);

_mapBtnRef = ref => (this._clearBtn = ref);

_addListeners() {
window.addEventListener('click', this._eventOutSide);
document.addEventListener('focus', this._eventOutSide, true);
}

_removeListeners() {
window.removeEventListener('click', this._eventOutSide);
document.removeEventListener('focus', this._eventOutSide, true);
}

_eventOutSide = ({ target }) => {
if (!this._container.contains(target)) {
this.setState({ isOpen: false });
this._removeListeners();
}
};

_showOnFocus = ({ target }) => {
if (
this.props.options.length &&
this._container.contains(target) &&
target !== this._clearBtn
) {
this.setState({ isOpen: true });
this._addListeners();
}
};

handleOptionClick = item => {
const { onSelect } = this.props;
onSelect && onSelect(item);
this.setState({
isOpen: false,
searchTerm: item.title
});
};

_clearSearch = event => {
event.stopPropagation();
this.setState({ searchTerm: '', isOpen: false });
};

_searchItem = ({ target: { value } }) => {
const trimmedValue = value.trim();

// Don't search if term is less than 2 characters
if (trimmedValue !== '' && trimmedValue.length > 1) {
this._callDebouncedSearch(trimmedValue);
}

this.setState({
searchTerm: value,
isOpen: true
});
};

// Reduce the amount of calls to the api while the user is typing
_callDebouncedSearch = debounce(value => {
this.props.onChange(value);
}, this.props.debounceTime);

render() {
const { placeholder, size, isLoading, renderLoader, options, isFocused } = this.props;
const { isOpen, searchTerm } = this.state;

return (
<div
className={cx('gds-search-select', {
'gds-search-select--open': isOpen
})}
onFocus={this._showOnFocus}
ref={this._mapContainerRef}>
<div className="gds-search-select__control">
<input
autoComplete="off"
className={cx('gds-form-group__text-input md', {
[`gds-form-group__text-input--${size}`]: size
})}
autoFocus={isFocused}
onChange={this._searchItem}
placeholder={placeholder}
type="text"
value={searchTerm}
/>
{searchTerm.length > 0 ? (
<button
ref={this._mapBtnRef}
onClick={this._clearSearch}
style={{
position: 'absolute',
padding: 0,
margin: 0,
top: '33%',
right: '0.6rem',
zIndex: 900,
display: 'inline-block',
border: 0,
borderRadius: 0
}}>
<i
className={cx('btl bt-times', {
'bt-sm': size === 'xs',
'bt-lg': size !== 'xs'
})}
style={{ display: 'inline-block' }}
/>
</button>
) : (
<i
className={cx('btl bt-search', {
'bt-lg': size === 'md',
'bt-sm': size === 'xs'
})}
style={{
position: 'absolute',
top: '31%',
right: '0.6rem',
zIndex: 900
}}
/>
)}

{isLoading && renderLoader && renderLoader()}
</div>
<SearchSelectOptions options={options} handleOptionClick={this.handleOptionClick} />
</div>
);
}
}

export default SearchSelect;
87 changes: 87 additions & 0 deletions components/molecules/SearchSelectOptions.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import charCodes from '../../constants/charCodes';

const EMPTY_RESULT = { id: 0, name: 'No result found' };

const styleReset = {
display: 'block',
width: '100%',
border: 0,
borderRadius: 0,
textAlign: 'left',
lineHeight: 'inherit',
fontFamily: 'inherit',
fontWeight: 'inherit',
fontSize: 'inherit'
};

const NEXT = [charCodes.ARROW_RIGHT, charCodes.ARROW_DOWN];
const PREV = [charCodes.ARROW_LEFT, charCodes.ARROW_UP];

class SearchSelectOptions extends Component {
static propTypes = {
options: PropTypes.arrayOf(
PropTypes.shape({
id: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
title: PropTypes.string.isRequired
})
),
handleOptionClick: PropTypes.func.isRequired
};

_handleFocus({ currentTarget, keyCode: charCode }) {
const nextBtn = currentTarget.nextElementSibling;
const prevBtn = currentTarget.previousElementSibling;

if (nextBtn && NEXT.includes(charCode)) {
nextBtn.firstChild.focus();
}

if (prevBtn && PREV.includes(charCode)) {
prevBtn.firstChild.focus();
}

if (!nextBtn && NEXT.includes(charCode)) {
currentTarget.parentElement.firstElementChild.firstChild.focus();
}

if (!prevBtn && PREV.includes(charCode)) {
currentTarget.parentElement.lastElementChild.firstChild.focus();
}
}

renderOptions() {
const { options, handleOptionClick } = this.props;

return options.length ? (
options.map(item => {
const { id, title } = item;
return (
<li key={id} onKeyUp={this._handleFocus}>
<button
className="gds-search-select__menu-item"
style={styleReset}
onClick={() => handleOptionClick({ id, title })}>
{title}
</button>
</li>
);
})
) : (
<li className="gds-search-select__menu-item" key={EMPTY_RESULT.id}>
{EMPTY_RESULT.name}
</li>
);
}

render() {
return (
<div className={`gds-search-select__menu`}>
<ul className="gds-search-select__menu-items ">{this.renderOptions()}</ul>
</div>
);
}
}

export default SearchSelectOptions;
15 changes: 15 additions & 0 deletions components/utils/debounce.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export default function debounce(func, wait, immediate) {
let timeout;
return function() {
const context = this;
const args = arguments;
const later = () => {
timeout = null;
if (!immediate) func.apply(context, args);
};
const callNow = immediate && !timeout;
clearTimeout(timeout);
timeout = setTimeout(later, wait);
if (callNow) func.apply(context, args);
};
}

0 comments on commit e8001b2

Please sign in to comment.