UNPKG

12 kBJavaScriptView Raw
1import isEqual from 'fast-deep-equal';
2import PropTypes from 'prop-types';
3import React from 'react';
4import TypeaheadManager from './TypeaheadManager';
5import { caseSensitiveType, checkPropType, defaultInputValueType, defaultSelectedType, highlightOnlyResultType, ignoreDiacriticsType, isRequiredForA11y, labelKeyType, optionType, selectedType, } from '../propTypes';
6import { addCustomOption, defaultFilterBy, getOptionLabel, getOptionProperty, getStringLabelKey, getUpdatedActiveIndex, getTruncatedOptions, isFunction, isShown, isString, noop, uniqueId, validateSelectedPropChange, } from '../utils';
7import { DEFAULT_LABELKEY } from '../constants';
8const 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};
38const 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};
60export 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}
83export function clearTypeahead(state, props) {
84 return {
85 ...getInitialState(props),
86 isFocused: state.isFocused,
87 selected: [],
88 text: '',
89 };
90}
91export function clickOrFocusInput(state) {
92 return {
93 ...state,
94 isFocused: true,
95 showMenu: true,
96 };
97}
98export 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}
109export function toggleMenu(state, props) {
110 return state.showMenu ? hideMenu(state, props) : { ...state, showMenu: true };
111}
112function 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}
118class 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}
326export default Typeahead;