1 | import isEqual from 'fast-deep-equal';
|
2 | import PropTypes from 'prop-types';
|
3 | import React from 'react';
|
4 | import TypeaheadManager from './TypeaheadManager';
|
5 | import { caseSensitiveType, checkPropType, defaultInputValueType, defaultSelectedType, highlightOnlyResultType, ignoreDiacriticsType, isRequiredForA11y, labelKeyType, optionType, selectedType, } from '../propTypes';
|
6 | import { addCustomOption, defaultFilterBy, getOptionLabel, getOptionProperty, getStringLabelKey, getUpdatedActiveIndex, getTruncatedOptions, isFunction, isShown, isString, noop, uniqueId, validateSelectedPropChange, } from '../utils';
|
7 | import { DEFAULT_LABELKEY } from '../constants';
|
8 | const propTypes = {
|
9 | allowNew: PropTypes.oneOfType([PropTypes.bool, PropTypes.func]),
|
10 | autoFocus: PropTypes.bool,
|
11 | caseSensitive: checkPropType(PropTypes.bool, caseSensitiveType),
|
12 | defaultInputValue: checkPropType(PropTypes.string, defaultInputValueType),
|
13 | defaultOpen: PropTypes.bool,
|
14 | defaultSelected: checkPropType(PropTypes.arrayOf(optionType), defaultSelectedType),
|
15 | filterBy: PropTypes.oneOfType([
|
16 | PropTypes.arrayOf(PropTypes.string.isRequired),
|
17 | PropTypes.func,
|
18 | ]),
|
19 | highlightOnlyResult: checkPropType(PropTypes.bool, highlightOnlyResultType),
|
20 | id: checkPropType(PropTypes.oneOfType([PropTypes.number, PropTypes.string]), isRequiredForA11y),
|
21 | ignoreDiacritics: checkPropType(PropTypes.bool, ignoreDiacriticsType),
|
22 | labelKey: checkPropType(PropTypes.oneOfType([PropTypes.string, PropTypes.func]), labelKeyType),
|
23 | maxResults: PropTypes.number,
|
24 | minLength: PropTypes.number,
|
25 | multiple: PropTypes.bool,
|
26 | onBlur: PropTypes.func,
|
27 | onChange: PropTypes.func,
|
28 | onFocus: PropTypes.func,
|
29 | onInputChange: PropTypes.func,
|
30 | onKeyDown: PropTypes.func,
|
31 | onMenuToggle: PropTypes.func,
|
32 | onPaginate: PropTypes.func,
|
33 | open: PropTypes.bool,
|
34 | options: PropTypes.arrayOf(optionType).isRequired,
|
35 | paginate: PropTypes.bool,
|
36 | selected: checkPropType(PropTypes.arrayOf(optionType), selectedType),
|
37 | };
|
38 | const defaultProps = {
|
39 | allowNew: false,
|
40 | autoFocus: false,
|
41 | caseSensitive: false,
|
42 | defaultInputValue: '',
|
43 | defaultOpen: false,
|
44 | defaultSelected: [],
|
45 | filterBy: [],
|
46 | highlightOnlyResult: false,
|
47 | ignoreDiacritics: true,
|
48 | labelKey: DEFAULT_LABELKEY,
|
49 | maxResults: 100,
|
50 | minLength: 0,
|
51 | multiple: false,
|
52 | onBlur: noop,
|
53 | onFocus: noop,
|
54 | onInputChange: noop,
|
55 | onKeyDown: noop,
|
56 | onMenuToggle: noop,
|
57 | onPaginate: noop,
|
58 | paginate: true,
|
59 | };
|
60 | export function getInitialState(props) {
|
61 | const { defaultInputValue, defaultOpen, defaultSelected, maxResults, multiple, } = props;
|
62 | let selected = props.selected
|
63 | ? props.selected.slice()
|
64 | : defaultSelected.slice();
|
65 | let text = defaultInputValue;
|
66 | if (!multiple && selected.length) {
|
67 | text = getOptionLabel(selected[0], props.labelKey);
|
68 | if (selected.length > 1) {
|
69 | selected = selected.slice(0, 1);
|
70 | }
|
71 | }
|
72 | return {
|
73 | activeIndex: -1,
|
74 | activeItem: undefined,
|
75 | initialItem: undefined,
|
76 | isFocused: false,
|
77 | selected,
|
78 | showMenu: defaultOpen,
|
79 | shownResults: maxResults,
|
80 | text,
|
81 | };
|
82 | }
|
83 | export function clearTypeahead(state, props) {
|
84 | return {
|
85 | ...getInitialState(props),
|
86 | isFocused: state.isFocused,
|
87 | selected: [],
|
88 | text: '',
|
89 | };
|
90 | }
|
91 | export function clickOrFocusInput(state) {
|
92 | return {
|
93 | ...state,
|
94 | isFocused: true,
|
95 | showMenu: true,
|
96 | };
|
97 | }
|
98 | export function hideMenu(state, props) {
|
99 | const { activeIndex, activeItem, initialItem, shownResults } = getInitialState(props);
|
100 | return {
|
101 | ...state,
|
102 | activeIndex,
|
103 | activeItem,
|
104 | initialItem,
|
105 | showMenu: false,
|
106 | shownResults,
|
107 | };
|
108 | }
|
109 | export function toggleMenu(state, props) {
|
110 | return state.showMenu ? hideMenu(state, props) : { ...state, showMenu: true };
|
111 | }
|
112 | function triggerInputChange(input, value) {
|
113 | const inputValue = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
|
114 | inputValue && inputValue.set && inputValue.set.call(input, value);
|
115 | const e = new Event('input', { bubbles: true });
|
116 | input.dispatchEvent(e);
|
117 | }
|
118 | class Typeahead extends React.Component {
|
119 | static propTypes = propTypes;
|
120 | static defaultProps = defaultProps;
|
121 | state = getInitialState(this.props);
|
122 | inputNode = null;
|
123 | isMenuShown = false;
|
124 | items = [];
|
125 | componentDidMount() {
|
126 | this.props.autoFocus && this.focus();
|
127 | }
|
128 | componentDidUpdate(prevProps, prevState) {
|
129 | const { labelKey, multiple, selected } = this.props;
|
130 | validateSelectedPropChange(selected, prevProps.selected);
|
131 | if (selected && !isEqual(selected, prevState.selected)) {
|
132 | this.setState({ selected });
|
133 | if (!multiple) {
|
134 | this.setState({
|
135 | text: selected.length ? getOptionLabel(selected[0], labelKey) : '',
|
136 | });
|
137 | }
|
138 | }
|
139 | }
|
140 | render() {
|
141 | const { onChange, ...props } = this.props;
|
142 | const mergedPropsAndState = { ...props, ...this.state };
|
143 | const { filterBy, labelKey, options, paginate, shownResults, text } = mergedPropsAndState;
|
144 | this.isMenuShown = isShown(mergedPropsAndState);
|
145 | this.items = [];
|
146 | let results = [];
|
147 | if (this.isMenuShown) {
|
148 | const cb = (isFunction(filterBy) ? filterBy : defaultFilterBy);
|
149 | results = options.filter((option) => cb(option, mergedPropsAndState));
|
150 | const shouldPaginate = paginate && results.length > shownResults;
|
151 | results = getTruncatedOptions(results, shownResults);
|
152 | if (addCustomOption(results, mergedPropsAndState)) {
|
153 | results.push({
|
154 | customOption: true,
|
155 | [getStringLabelKey(labelKey)]: text,
|
156 | });
|
157 | }
|
158 | if (shouldPaginate) {
|
159 | results.push({
|
160 | [getStringLabelKey(labelKey)]: '',
|
161 | paginationOption: true,
|
162 | });
|
163 | }
|
164 | }
|
165 | return (React.createElement(TypeaheadManager, { ...mergedPropsAndState, hideMenu: this.hideMenu, inputNode: this.inputNode, inputRef: this.inputRef, isMenuShown: this.isMenuShown, onActiveItemChange: this._handleActiveItemChange, onAdd: this._handleSelectionAdd, onBlur: this._handleBlur, onChange: this._handleInputChange, onClear: this._handleClear, onClick: this._handleClick, onFocus: this._handleFocus, onHide: this.hideMenu, onInitialItemChange: this._handleInitialItemChange, onKeyDown: this._handleKeyDown, onMenuItemClick: this._handleMenuItemSelect, onRemove: this._handleSelectionRemove, results: results, setItem: this.setItem, toggleMenu: this.toggleMenu }));
|
166 | }
|
167 | blur = () => {
|
168 | this.inputNode && this.inputNode.blur();
|
169 | this.hideMenu();
|
170 | };
|
171 | clear = () => {
|
172 | this.setState(clearTypeahead);
|
173 | };
|
174 | focus = () => {
|
175 | this.inputNode && this.inputNode.focus();
|
176 | };
|
177 | getInput = () => {
|
178 | return this.inputNode;
|
179 | };
|
180 | inputRef = (inputNode) => {
|
181 | this.inputNode = inputNode;
|
182 | };
|
183 | setItem = (item, position) => {
|
184 | this.items[position] = item;
|
185 | };
|
186 | hideMenu = () => {
|
187 | this.setState(hideMenu);
|
188 | };
|
189 | toggleMenu = () => {
|
190 | this.setState(toggleMenu);
|
191 | };
|
192 | _handleActiveIndexChange = (activeIndex) => {
|
193 | this.setState((state) => ({
|
194 | activeIndex,
|
195 | activeItem: activeIndex >= 0 ? state.activeItem : undefined,
|
196 | }));
|
197 | };
|
198 | _handleActiveItemChange = (activeItem) => {
|
199 | if (!isEqual(activeItem, this.state.activeItem)) {
|
200 | this.setState({ activeItem });
|
201 | }
|
202 | };
|
203 | _handleBlur = (e) => {
|
204 | e.persist();
|
205 | this.setState({ isFocused: false }, () => this.props.onBlur(e));
|
206 | };
|
207 | _handleChange = (selected) => {
|
208 | this.props.onChange && this.props.onChange(selected);
|
209 | };
|
210 | _handleClear = () => {
|
211 | this.inputNode && triggerInputChange(this.inputNode, '');
|
212 | this.setState(clearTypeahead, () => {
|
213 | if (this.props.multiple) {
|
214 | this._handleChange([]);
|
215 | }
|
216 | });
|
217 | };
|
218 | _handleClick = (e) => {
|
219 | e.persist();
|
220 | const onClick = this.props.inputProps?.onClick;
|
221 | this.setState(clickOrFocusInput, () => isFunction(onClick) && onClick(e));
|
222 | };
|
223 | _handleFocus = (e) => {
|
224 | e.persist();
|
225 | this.setState(clickOrFocusInput, () => this.props.onFocus(e));
|
226 | };
|
227 | _handleInitialItemChange = (initialItem) => {
|
228 | if (!isEqual(initialItem, this.state.initialItem)) {
|
229 | this.setState({ initialItem });
|
230 | }
|
231 | };
|
232 | _handleInputChange = (e) => {
|
233 | e.persist();
|
234 | const text = e.currentTarget.value;
|
235 | const { multiple, onInputChange } = this.props;
|
236 | const shouldClearSelections = this.state.selected.length && !multiple;
|
237 | this.setState((state, props) => {
|
238 | const { activeIndex, activeItem, shownResults } = getInitialState(props);
|
239 | return {
|
240 | activeIndex,
|
241 | activeItem,
|
242 | selected: shouldClearSelections ? [] : state.selected,
|
243 | showMenu: true,
|
244 | shownResults,
|
245 | text,
|
246 | };
|
247 | }, () => {
|
248 | onInputChange(text, e);
|
249 | shouldClearSelections && this._handleChange([]);
|
250 | });
|
251 | };
|
252 | _handleKeyDown = (e) => {
|
253 | const { activeItem } = this.state;
|
254 | if (!this.isMenuShown) {
|
255 | if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
256 | this.setState({ showMenu: true });
|
257 | }
|
258 | this.props.onKeyDown(e);
|
259 | return;
|
260 | }
|
261 | switch (e.key) {
|
262 | case 'ArrowUp':
|
263 | case 'ArrowDown':
|
264 | e.preventDefault();
|
265 | this._handleActiveIndexChange(getUpdatedActiveIndex(this.state.activeIndex, e.key, this.items));
|
266 | break;
|
267 | case 'Enter':
|
268 | e.preventDefault();
|
269 | activeItem && this._handleMenuItemSelect(activeItem, e);
|
270 | break;
|
271 | case 'Escape':
|
272 | case 'Tab':
|
273 | this.hideMenu();
|
274 | break;
|
275 | default:
|
276 | break;
|
277 | }
|
278 | this.props.onKeyDown(e);
|
279 | };
|
280 | _handleMenuItemSelect = (option, e) => {
|
281 | if (getOptionProperty(option, 'paginationOption')) {
|
282 | this._handlePaginate(e);
|
283 | }
|
284 | else {
|
285 | this._handleSelectionAdd(option);
|
286 | }
|
287 | };
|
288 | _handlePaginate = (e) => {
|
289 | e.persist();
|
290 | this.setState((state, props) => ({
|
291 | shownResults: state.shownResults + props.maxResults,
|
292 | }), () => this.props.onPaginate(e, this.state.shownResults));
|
293 | };
|
294 | _handleSelectionAdd = (option) => {
|
295 | const { multiple, labelKey } = this.props;
|
296 | let selected;
|
297 | let selection = option;
|
298 | let text;
|
299 | if (!isString(selection) && selection.customOption) {
|
300 | selection = { ...selection, id: uniqueId('new-id-') };
|
301 | }
|
302 | if (multiple) {
|
303 | selected = this.state.selected.concat(selection);
|
304 | text = '';
|
305 | }
|
306 | else {
|
307 | selected = [selection];
|
308 | text = getOptionLabel(selection, labelKey);
|
309 | }
|
310 | this.setState((state, props) => ({
|
311 | ...hideMenu(state, props),
|
312 | initialItem: selection,
|
313 | selected,
|
314 | text,
|
315 | }), () => this._handleChange(selected));
|
316 | };
|
317 | _handleSelectionRemove = (selection) => {
|
318 | const selected = this.state.selected.filter((option) => !isEqual(option, selection));
|
319 | this.focus();
|
320 | this.setState((state, props) => ({
|
321 | ...hideMenu(state, props),
|
322 | selected,
|
323 | }), () => this._handleChange(selected));
|
324 | };
|
325 | }
|
326 | export default Typeahead;
|