363 lines
12 KiB
JavaScript
363 lines
12 KiB
JavaScript
// Simple Dropdown/Autocomplete with single/multiple selection and easy customisation via CSS modules
|
|
// Version 2019-09-03
|
|
// License: LGPLv3.0+
|
|
// (c) Vitaliy Filippov 2019+
|
|
|
|
import React from 'react';
|
|
import PropTypes from 'prop-types';
|
|
|
|
import autocomplete_css from './autocomplete.css';
|
|
import Picker from './Picker.js';
|
|
|
|
export default class Selectbox extends React.PureComponent
|
|
{
|
|
static propTypes = {
|
|
// multi-select
|
|
multiple: PropTypes.bool,
|
|
// make text input readonly (disable user input). still allows value change
|
|
readOnly: PropTypes.bool,
|
|
// show "clear" icon (cross)
|
|
allowClear: PropTypes.bool,
|
|
// select/autocomplete options - either an array of objects, or a { [string]: string } object
|
|
source: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
|
|
// current value
|
|
value: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number]),
|
|
// change callback
|
|
onChange: PropTypes.func,
|
|
// item name key - default "name"
|
|
labelKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
// item id key - default "id"
|
|
valueKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
|
|
// automatically filter autocomplete options based on user input if `true`
|
|
suggestionMatch: PropTypes.oneOfType([PropTypes.bool, PropTypes.oneOf(['disabled'])]),
|
|
// additional message to display below autocomplete options (arbitrary HTML, for example "...")
|
|
suggestionMsg: PropTypes.any,
|
|
// disable the whole input
|
|
disabled: PropTypes.bool,
|
|
// placeholder to display when the input is empty
|
|
placeholder: PropTypes.string,
|
|
// minimum suggestion list width in pixels
|
|
minWidth: PropTypes.number,
|
|
// change theme (CSS module) for this input
|
|
theme: PropTypes.object,
|
|
// additional CSS class name for the input
|
|
className: PropTypes.string,
|
|
// additional CSS styles for the input
|
|
style: PropTypes.object,
|
|
// additional event listener for onFocus
|
|
onFocus: PropTypes.func,
|
|
// additional event listener for onBlur
|
|
onBlur: PropTypes.func,
|
|
// additional event listener for user text input
|
|
onQueryChange: PropTypes.func,
|
|
}
|
|
|
|
state = {
|
|
shown: false,
|
|
active: null,
|
|
query: null,
|
|
inputWidth: 20,
|
|
}
|
|
|
|
setQuery = (ev) =>
|
|
{
|
|
const query = ev.target.value;
|
|
this.setState({ query });
|
|
const f = this.props.onQueryChange;
|
|
if (f)
|
|
{
|
|
f(query);
|
|
}
|
|
if (!query.length && !this.props.multiple && this.props.allowClear)
|
|
{
|
|
this.clear();
|
|
}
|
|
}
|
|
|
|
onKeyDown = (ev) =>
|
|
{
|
|
if ((ev.which == 40 || ev.which == 38) && this.filtered_items.length)
|
|
{
|
|
// up / down
|
|
this.setState({
|
|
active: this.state.active == null ? 0 : (
|
|
(this.state.active + (event.which === 40 ? 1 : this.filtered_items.length-1)) % this.filtered_items.length
|
|
),
|
|
});
|
|
if (!this.picker.state.focused)
|
|
{
|
|
this.picker.focus();
|
|
}
|
|
}
|
|
else if ((ev.which == 10 || ev.which == 13) && this.state.active != null &&
|
|
this.state.active < this.filtered_items.length)
|
|
{
|
|
// enter
|
|
this.onMouseDown();
|
|
}
|
|
}
|
|
|
|
clear = () =>
|
|
{
|
|
this.setState({ query: null });
|
|
const f = this.props.onChange;
|
|
f && f(null);
|
|
}
|
|
|
|
removeValue = (ev) =>
|
|
{
|
|
const n = ev.currentTarget.getAttribute('data-n');
|
|
if (n != null)
|
|
{
|
|
const v = [ ...this.props.value ];
|
|
v.splice(n, 1);
|
|
const f = this.props.onChange;
|
|
f && f(v);
|
|
}
|
|
}
|
|
|
|
onMouseDown = () =>
|
|
{
|
|
this.setState({ query: null });
|
|
this.picker.blur();
|
|
const sel = this.filtered_items[this.state.active][this.props.valueKey||'id'];
|
|
let value = sel;
|
|
if (this.props.multiple)
|
|
{
|
|
const already = (this.props.value||[]).indexOf(sel);
|
|
if (already < 0)
|
|
{
|
|
// add
|
|
value = [ ...(this.props.value||[]), sel ];
|
|
}
|
|
else
|
|
{
|
|
// remove
|
|
value = [ ...this.props.value ];
|
|
value.splice(already, 1);
|
|
}
|
|
}
|
|
const f = this.props.onChange;
|
|
f && f(value);
|
|
}
|
|
|
|
onMouseOver = (ev) =>
|
|
{
|
|
let e = ev.target;
|
|
while (e && e != ev.currentTarget && !e.id)
|
|
{
|
|
e = e.parentNode;
|
|
}
|
|
if (e && e.id)
|
|
{
|
|
this.setState({ active: e.id });
|
|
}
|
|
}
|
|
|
|
focusInput = () =>
|
|
{
|
|
if (!this.props.disabled)
|
|
{
|
|
this.input.focus();
|
|
}
|
|
}
|
|
|
|
onFocus = () =>
|
|
{
|
|
if (this.props.disabled)
|
|
{
|
|
return;
|
|
}
|
|
if (!this.props.multiple && this.state.active === null)
|
|
{
|
|
const v = this.props.value, vk = this.props.valueKey||'id';
|
|
for (let i = 0; i < this.filtered_items.length; i++)
|
|
{
|
|
if (v == this.filtered_items[i][vk])
|
|
{
|
|
this.setState({ active: i });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
this.picker.focus();
|
|
const f = this.props.onFocus;
|
|
f && f();
|
|
}
|
|
|
|
onBlur = () =>
|
|
{
|
|
this.picker.blur();
|
|
const f = this.props.onBlur;
|
|
f && f();
|
|
if (!this.props.multiple && !this.props.allowClear && !this.input.value.length)
|
|
{
|
|
this.setState({ query: null });
|
|
}
|
|
}
|
|
|
|
renderInput = (p) =>
|
|
{
|
|
const theme = this.props.theme || autocomplete_css;
|
|
const value = this.state.query == null
|
|
? (this.props.multiple ? '' : this.item_hash[this.props.value]||'')
|
|
: this.state.query;
|
|
const input = <input
|
|
readOnly={this.props.readOnly}
|
|
disabled={this.props.disabled}
|
|
placeholder={!this.props.multiple || !this.props.value || !this.props.value.length ? this.props.placeholder : undefined}
|
|
ref={this.setInput}
|
|
className={theme.inputElement}
|
|
style={this.props.multiple ? { width: this.state.inputWidth+'px' } : undefined}
|
|
onFocus={this.onFocus}
|
|
onBlur={this.onBlur}
|
|
value={value}
|
|
onChange={this.setQuery}
|
|
onKeyDown={this.onKeyDown}
|
|
/>;
|
|
return (<div ref={p.ref}
|
|
className={theme.input +
|
|
(p.focused ? ' '+(theme.focused||'') : '') +
|
|
(this.props.disabled ? ' '+(theme.disabled||'') : '') +
|
|
(this.props.readOnly ? ' '+(theme.readonly||'') : '') +
|
|
(this.props.multiple
|
|
? ' '+(theme.multiple||'')
|
|
: (this.props.allowClear ? ' '+(theme.withClear||'') : '')) +
|
|
(this.props.className ? ' '+this.props.className : '')}
|
|
style={this.props.style}
|
|
onClick={this.focusInput}>
|
|
{this.props.multiple
|
|
? <div className={theme.values}>
|
|
<span className={theme.inputElement+' '+theme.sizer} ref={this.setSizer}>
|
|
{this.props.multiple && (!this.props.value || !this.props.value.length) && value === ''
|
|
? (this.props.placeholder || '') : value}
|
|
</span>
|
|
{(this.props.value||[]).map((id, idx) => <span className={theme.value} key={idx}>
|
|
{this.props.allowClear && !this.props.disabled
|
|
? <span data-n={idx} className={theme.clearValue} onClick={this.removeValue}></span>
|
|
: null}
|
|
{this.item_hash[id]}
|
|
</span>)}
|
|
{input}
|
|
</div>
|
|
: input}
|
|
{this.props.allowClear && !this.props.multiple && this.props.value != null
|
|
? <div className={theme.clear} onClick={this.clear}></div>
|
|
: null}
|
|
</div>);
|
|
}
|
|
|
|
animateSuggestions = (e) =>
|
|
{
|
|
if (e)
|
|
{
|
|
e.style.visibility = 'hidden';
|
|
e.style.overflowY = 'hidden';
|
|
const anim = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.msRequestAnimationFrame;
|
|
anim(() =>
|
|
{
|
|
e.style.visibility = '';
|
|
e.style.maxHeight = '1px';
|
|
anim(() =>
|
|
{
|
|
e.style.maxHeight = '100%';
|
|
const end = () => { e.style.overflowY = 'auto'; e.removeEventListener('transitionend', end); };
|
|
e.addEventListener('transitionend', end);
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
renderSuggestions = () =>
|
|
{
|
|
const theme = this.props.theme || autocomplete_css;
|
|
return (<div ref={this.animateSuggestions}
|
|
className={theme.suggestions}
|
|
onMouseOver={this.onMouseOver}>
|
|
{this.filtered_items.map((e, i) => (<div key={i} id={i} onMouseDown={this.onMouseDown}
|
|
className={theme.suggestion+(this.state.active == i ? ' '+theme.active : '')}>
|
|
{e[this.props.labelKey||'name']}
|
|
</div>))}
|
|
{this.props.suggestionMsg}
|
|
</div>);
|
|
}
|
|
|
|
setSizer = (e) =>
|
|
{
|
|
this.sizer = e;
|
|
}
|
|
|
|
setPicker = (e) =>
|
|
{
|
|
this.picker = e;
|
|
}
|
|
|
|
setInput = (e) =>
|
|
{
|
|
this.input = e;
|
|
}
|
|
|
|
render()
|
|
{
|
|
if (!this.prevProps || this.props.source != this.prevProps.source)
|
|
{
|
|
if (!this.props.source)
|
|
{
|
|
this.items = [];
|
|
this.item_hash = {};
|
|
}
|
|
else if (this.props.source instanceof Array)
|
|
{
|
|
this.items = this.props.source;
|
|
this.item_hash = this.items.reduce((a, c) => { a[c[this.props.valueKey||'id']] = c[this.props.labelKey||'name']; return a; }, {});
|
|
}
|
|
else
|
|
{
|
|
this.items = Object.keys(this.props.source).map(id => ({ id, name: this.props.source[id] }));
|
|
this.item_hash = this.props.source;
|
|
}
|
|
}
|
|
if (!this.prevProps || this.props.suggestionMatch != this.prevProps.suggestionMatch ||
|
|
this.state.query != this.prevState.query)
|
|
{
|
|
if (!this.props.suggestionMatch || this.props.suggestionMatch == 'disabled' || !this.state.query)
|
|
{
|
|
this.filtered_items = this.items;
|
|
}
|
|
else
|
|
{
|
|
const n = this.props.labelKey||'name';
|
|
const q = this.state.query.toLowerCase();
|
|
this.filtered_items = this.items.filter(e => e[n].toLowerCase().indexOf(q) >= 0);
|
|
}
|
|
}
|
|
this.prevProps = this.props;
|
|
this.prevState = this.state;
|
|
return (<Picker
|
|
ref={this.setPicker}
|
|
minWidth={this.props.minWidth}
|
|
clearOnClick={true}
|
|
renderInput={this.renderInput}
|
|
renderPicker={this.renderSuggestions}
|
|
/>);
|
|
}
|
|
|
|
componentDidUpdate()
|
|
{
|
|
if (this.sizer)
|
|
{
|
|
this.setState({ inputWidth: this.sizer.offsetWidth });
|
|
if (this.prevHeight && this.picker.input.offsetHeight != this.prevHeight)
|
|
{
|
|
this.picker.calculateDirection();
|
|
}
|
|
this.prevHeight = this.picker.input.offsetHeight;
|
|
}
|
|
}
|
|
|
|
componentDidMount()
|
|
{
|
|
this.componentDidUpdate();
|
|
}
|
|
}
|