react-sm-select
Version:
React Multi/Single Select Component
494 lines (428 loc) • 13.9 kB
JavaScript
import React from 'react';
import T from 'prop-types';
import * as u from './utils';
import { MODE } from './consts';
import { Header, Value, DefArrow, DefLoading } from './Header';
import { SelectAll, Option } from './dropdown';
export class MultiSelect extends React.Component {
static displayName = 'MultiSelect';
static propTypes = {
// data
id: T.string,
mode: T.oneOf([MODE.LIST, MODE.TAGS, MODE.COUNTER, MODE.SINGLE]),
options: T.arrayOf(T.shape({
value: T.string,
label: T.string,
})).isRequired,
value: T.arrayOf(T.string),
resetTo: T.arrayOf(T.string),
maxOptionsToRender: T.number,
// methods
onChange: T.func,
onBlur: T.func,
onClose: T.func,
// custom rendering
Value: T.func,
Tag: T.func,
Loading: T.func,
Arrow: T.func,
Option: T.func,
// search
filterOptions: T.func,
// labels / placeholders
valuePlaceholder: T.string,
allSelectedLabel: T.string,
counterLabel: T.string,
searchPlaceholder: T.string,
searchMorePlaceholder: T.string,
selectAllLabel: T.string,
// controls
disabled: T.bool,
shouldToggleOnHover: T.bool,
isLoading: T.bool,
removableTag: T.bool,
resetable: T.bool,
enableSearch: T.bool,
hasSelectAll: T.bool,
stopClickPropagation: T.bool,
};
static defaultProps = {
mode: MODE.LIST,
value: [],
resetTo: [],
Loading: DefLoading,
Arrow: DefArrow,
filterOptions: u.defaultFilterOptions,
valuePlaceholder: 'Select',
allSelectedLabel: 'All items are selected',
searchPlaceholder: 'Search',
searchMorePlaceholder: 'Search to see more ...',
selectAllLabel: 'Select All',
shouldToggleOnHover: false,
removableTag: true,
resetable: false,
enableSearch: false,
hasSelectAll: false,
stopClickPropagation: false,
};
constructor(p) {
super(p);
this.state = {
value: p.isLoading ? p.value : u.omitDirtyValues(p.options, p.value, this.isSingle()),
expanded: false,
hasFocus: false,
selectAll: false,
focusIndex: p.enableSearch ? -1 : -2,
searchText: '',
mouseHover: false,
};
this.multiSelectRef = React.createRef();
this.headerRef = React.createRef();
this.searchRef = React.createRef();
this.hasListener = false;
}
componentDidUpdate(pp, ps) {
const { props: p, state: s } = this;
const loadingStart = !pp.isLoading && p.isLoading;
const loadingEnd = pp.isLoading && !p.isLoading;
const optionsChanges = pp.options.length !== p.options.length;
const getClearValue = (value) => loadingStart ? value : u.omitDirtyValues(p.options, value, this.isSingle());
const clearCurrValue = getClearValue(p.value);
const clearPrevValue = getClearValue(pp.value);
if (!u.areArraysEqual(clearPrevValue, clearCurrValue) || loadingStart || loadingEnd || optionsChanges)
this.setState({ value: clearCurrValue });
// Call onClose if it was closed
if (ps.expanded === true && s.expanded === false) this.onEvent('onClose');
// Call onChange if value was changed
if (!u.areArraysEqual(ps.value, s.value)) this.onEvent('onChange');
// Subscribe - Unsubscribe for click outside if enabled - disabled
if (pp.disabled && !p.disabled) {
this.hasListener = true;
u.attachDocumentClickListener(this.handleDocumentClick);
}
if (!pp.disabled && p.disabled) {
this.hasListener = false;
u.removeDocumentClickListener(this.handleDocumentClick);
}
}
componentWillUnmount() {
if (this.hasListener) u.removeDocumentClickListener(this.handleDocumentClick);
}
// Common
/**
* Checks if mode is single
* @returns {boolean}
*/
isSingle = () => this.props.mode === MODE.SINGLE;
/**
* Handle click on document, to detect click outside
*/
handleDocumentClick = () => {
if (this.props.disabled) return;
if (!this.state.mouseHover) {
this.setState({ expanded: false, hasFocus: false });
u.removeDocumentClickListener(this.handleDocumentClick);
this.onEvent('onBlur');
}
};
/**
* Handle MultiSelect click to subscribe for click outside
*/
handleClick = () => {
if (!this.props.disabled && !this.hasListener) u.attachDocumentClickListener(this.handleDocumentClick);
};
/**
* Handle focus to control focus state
*/
handleFocus = () => {
this.setState(({hasFocus}) => !hasFocus ? { hasFocus: true } : null);
};
/**
* Handle blur to control focus state
*/
handleBlur = () => {
this.setState(({hasFocus}) => hasFocus ? { hasFocus: false } : null);
};
/**
* Toggle MultiSelect DropDown
* @param event
* @param value Boolean
*/
toggleDropDown = (event, value) => {
const { props: p } = this;
if (p.disabled || p.isLoading) return;
this.setState(({expanded}) => ({
expanded: value !== undefined ? value : !expanded,
...(!expanded ? {
focusIndex: p.enableSearch ? -1 : -2,
searchText: '',
} : {}),
}));
if (event && p.stopClickPropagation) u.stopPreventPropagation(event);
};
/**
* Handle hover to trigger DropDown list
* @param expanded Boolean
*/
handleHover = expanded => {
this.setState({ mouseHover: expanded });
if (this.props.shouldToggleOnHover) this.toggleDropDown(null, expanded);
};
/**
* Detect if SelectAll should be visible
* @returns {boolean|Boolean}
*/
isSelectAllVisible = () => !this.isSingle() && this.props.hasSelectAll && !this.state.searchText;
/**
* Handle focus over search field and header depend on focus index
* @param focusIndex Number
*/
handleFocusControl = focusIndex => {
if (focusIndex === -1) this.searchRef.current.focus();
if (focusIndex === -2) this.headerRef.current.focus();
};
// Keyboard Navigation
/**
* Handle Keyboard Key Down and set focus
* Skip search and select all if needed
* @param event
*/
keyDown = event => {
const { props: p, state: s } = this;
if (!s.expanded) this.toggleDropDown(null, true);
else this.setState(({focusIndex}) => {
let nextIndex = focusIndex + 1;
if (nextIndex === -1) nextIndex = p.enableSearch ? nextIndex : nextIndex + 1;
if (nextIndex === 0) nextIndex = this.isSelectAllVisible() ? nextIndex : nextIndex + 1;
return s.focusIndex < this.filteredOptions().length ? { focusIndex: nextIndex } : null;
}, () => {
this.handleFocusControl(this.state.focusIndex);
});
u.stopPreventPropagation(event);
};
/**
* Handle Keyboard Key Up and set focus
* Skip search and select all if needed
* @param event
*/
keyUp = event => {
const { props: p, state: s } = this;
if (s.expanded) {
if (s.focusIndex === -2) this.toggleDropDown(null, false);
else this.setState(({focusIndex}) => {
let nextIndex = focusIndex - 1;
if (nextIndex === 0) nextIndex = this.isSelectAllVisible() ? nextIndex : nextIndex - 1;
if (nextIndex === -1) nextIndex = p.enableSearch ? nextIndex : nextIndex - 1;
return { focusIndex: nextIndex }
}, () => {
this.handleFocusControl(this.state.focusIndex);
});
}
u.stopPreventPropagation(event);
};
/**
* Resets value if it needed
* @param event
*/
clearValue = event => {
if (this.props.resetable && this.state.focusIndex === -2) this.reset(event);
};
/**
* Handle Key Press
* @param event
*/
handleKeyPress = event => {
({
[event.which]: () => {},
8: () => this.clearValue(event), // BackSpace
9: () => this.toggleDropDown(null, false), // Tab
27: () => { // Esc
this.toggleDropDown(null, false);
this.handleFocusControl(-2);
u.stopPreventPropagation(event);
},
38: () => this.keyUp(event), // Up
40: () => this.keyDown(event), // Down
}[event.which])();
};
// Value
/**
* Add selected value to selected or select in single mode
* @param optionValue value string
*/
select = optionValue => {
if (this.isSingle()) this.setState({ value: [optionValue] }, () => this.toggleDropDown(null,false));
else this.setState({ value: [...this.state.value, optionValue] });
};
/**
* Removes/Deselect value at index
* @param index Number value index
* @param event
*/
deselect = (index, event) => {
const { value } = this.state;
this.setState({ value: [...value.slice(0, index), ...value.slice(index + 1)] });
if (event) u.stopPreventPropagation(event);
};
/**
* Resets value to provided state or default
* @param event
*/
reset = event => {
const { props: p } = this;
if (!p.disabled || !p.isLoading) this.setState({ value: p.resetTo });
u.stopPreventPropagation(event);
};
// Search
/**
* Handle search field changes
* @param event
*/
handleSearchChange = event => {
this.setState({ searchText: event.target.value });
};
// Select All
/**
* Checks if all options are selected
* @returns {boolean}
*/
allAreSelected = () => this.props.options.length === this.state.value.length;
/**
* Select/Deselect all options
*/
toggleAll = () => {
if (!this.allAreSelected()) {
const value = this.props.options.map(option => option.value);
this.setState({ value, focusIndex: 0 });
} else this.setState({ value: [], focusIndex: 0 });
};
// Options
/**
* Returns array of filtered options used by search field
* @returns []
*/
filteredOptions = () => {
const { state: s, props: p } = this;
const optionsToRender = p.filterOptions(p.options, s.searchText);
return p.maxOptionsToRender
? optionsToRender.slice(0, p.maxOptionsToRender)
: optionsToRender;
};
/**
* Handle Option Click
* @param optionValue String
* @param index Number
*/
optionClick = (optionValue, index) => {
const { state: s } = this;
const valueIndex = s.value.indexOf(optionValue);
if (valueIndex === -1 || this.isSingle()) this.select(optionValue);
else this.deselect(valueIndex);
this.setState({ focusIndex: index + 1 });
};
// Events
/**
* Any event coming from props
* @param event
*/
onEvent = event => {
const { props: p, state: s } = this;
if (p[event]) p[event](s.value)
};
render() {
const { props: p, state: s } = this;
return (
<div className="MultiSelect"
id={p.id}
ref={this.multiSelectRef}
onMouseDown={this.handleClick}
onMouseEnter={() => this.handleHover(true)}
onMouseLeave={() => this.handleHover(false)}
onKeyDown={this.handleKeyPress}
onFocus={this.handleFocus}
onBlur={this.handleBlur}
>
<Header
nodeRef={this.headerRef}
focused={s.hasFocus}
expanded={s.expanded}
disabled={p.disabled}
selected={s.focusIndex === -2}
onClick={this.toggleDropDown}
>
<div className={u.classes('Header__value', {
'Header__value--resetable': p.resetable && (!!s.value.length || !!p.resetTo.length),
})}>
<Value
mode={p.mode}
options={p.options}
value={s.value}
Value={p.Value}
valuePlaceholder={p.valuePlaceholder}
allSelectedLabel={p.allSelectedLabel}
counterLabel={p.counterLabel}
Tag={p.Tag}
removableTag={p.removableTag}
onRemove={this.deselect}
/>
</div>
<div className="Header__controls">
{
p.resetable && (!!s.value.length || !!p.resetTo.length)
&& <div className="Header__reset" onClick={this.reset}>✕</div>
}
{p.isLoading && <p.Loading />}
{!p.isLoading && <p.Arrow
value={s.value}
options={p.options}
hasFocus={s.hasFocus}
disabled={p.disabled}
expanded={s.expanded}
/>}
</div>
</Header>
{s.expanded && <div className="DropDown" role="listbox">
{p.enableSearch && (
<input
type="text"
className={u.classes('DropDown__searchField', {
'DropDown__searchField--selected': s.focusIndex === -1,
})}
placeholder={!p.maxOptionsToRender ? p.searchPlaceholder : p.searchMorePlaceholder}
value={s.searchText}
ref={this.searchRef}
onChange={this.handleSearchChange}
onMouseDown={() => this.setState({ focusIndex: -1 })}
autoFocus={s.focusIndex === -1}
/>
)}
<ul className="OptionList">
{this.isSelectAllVisible() && (
<SelectAll
key={s.value || this.filteredOptions()}
Option={p.Option}
focused={s.focusIndex === 0}
checked={this.allAreSelected()}
selectAllLabel={p.selectAllLabel}
onClick={this.toggleAll}
/>
)}
{this.filteredOptions().map((option, index) => (
<li className="OptionList__item" key={option.value}>
<Option
key={s.value || this.filteredOptions()}
isSingle={this.isSingle()}
focused={s.focusIndex === index + 1}
checked={s.value.includes(option.value)}
option={option}
Option={p.Option}
onClick={() => this.optionClick(option.value, index)}
/>
</li>
))}
</ul>
</div>}
</div>
);
}
}