selectbox/Selectbox.js

290 lines
9.4 KiB
JavaScript

// Simple Dropdown/Autocomplete with single/multiple selection and easy customisation via CSS modules
// Version 2020-04-27
// License: LGPLv3.0+
// (c) Vitaliy Filippov 2019+
import React from 'react';
import PropTypes from 'prop-types';
import autocomplete_css from './autocomplete.css';
import PickerMenu from './PickerMenu.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,
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();
}
}
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);
}
}
onSelectItem = (item) =>
{
this.setState({ query: null });
this.picker.blur();
const sel = item[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);
}
focusInput = () =>
{
if (!this.props.disabled)
{
this.input.focus();
}
}
onFocus = () =>
{
if (this.props.disabled)
{
return;
}
if (!this.props.multiple && this.picker.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.picker.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={p.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>);
}
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.source != this.prevProps.source ||
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 (<PickerMenu
ref={this.setPicker}
minWidth={this.props.minWidth}
clearOnClick={true}
renderInput={this.renderInput}
items={this.filtered_items}
labelKey={this.props.labelKey||'name'}
afterItems={this.props.suggestionMsg}
onSelectItem={this.onSelectItem}
/>);
}
componentDidUpdate()
{
if (this.sizer)
{
this.setState({ inputWidth: this.sizer.offsetWidth });
}
}
componentDidMount()
{
this.componentDidUpdate();
}
}