UNPKG

15.2 kBJavaScriptView Raw
1const _excluded = ["id", "autoFocus", "textField", "dataKey", "value", "defaultValue", "onChange", "open", "defaultOpen", "onToggle", "searchTerm", "defaultSearchTerm", "onSearch", "filter", "allowCreate", "delay", "focusFirstItem", "className", "containerClassName", "placeholder", "busy", "disabled", "readOnly", "selectIcon", "busySpinner", "dropUp", "tabIndex", "popupTransition", "name", "autoComplete", "onSelect", "onCreate", "onKeyPress", "onKeyDown", "onClick", "inputProps", "listProps", "renderListItem", "renderListGroup", "optionComponent", "renderValue", "groupBy", "onBlur", "onFocus", "listComponent", "popupComponent", "data", "messages"];
2
3function _extends() { _extends = Object.assign || function (target) { for (var i = 1; i < arguments.length; i++) { var source = arguments[i]; for (var key in source) { if (Object.prototype.hasOwnProperty.call(source, key)) { target[key] = source[key]; } } } return target; }; return _extends.apply(this, arguments); }
4
5function _objectWithoutPropertiesLoose(source, excluded) { if (source == null) return {}; var target = {}; var sourceKeys = Object.keys(source); var key, i; for (i = 0; i < sourceKeys.length; i++) { key = sourceKeys[i]; if (excluded.indexOf(key) >= 0) continue; target[key] = source[key]; } return target; }
6
7import cn from 'classnames';
8import PropTypes from 'prop-types';
9import React, { useImperativeHandle, useMemo, useRef, useState } from 'react';
10import { useUncontrolledProp } from 'uncontrollable';
11import useTimeout from '@restart/hooks/useTimeout';
12import AddToListOption, { CREATE_OPTION } from './AddToListOption';
13import DropdownListInput from './DropdownListInput';
14import { caretDown } from './Icon';
15import List from './List';
16import { FocusListContext, useFocusList } from './FocusListContext';
17import BasePopup from './Popup';
18import Widget from './Widget';
19import WidgetPicker from './WidgetPicker';
20import { useMessagesWithDefaults } from './messages';
21import { useActiveDescendant } from './A11y';
22import { useFilteredData, presets } from './Filter';
23import * as CustomPropTypes from './PropTypes';
24import canShowCreate from './canShowCreate';
25import { useAccessors } from './Accessors';
26import useAutoFocus from './useAutoFocus';
27import useDropdownToggle from './useDropdownToggle';
28import useFocusManager from './useFocusManager';
29import { notify, useFirstFocusedRender, useInstanceId } from './WidgetHelpers';
30import PickerCaret from './PickerCaret';
31const propTypes = {
32 value: PropTypes.any,
33
34 /**
35 * @type {function (
36 * dataItems: ?any,
37 * metadata: {
38 * lastValue: ?any,
39 * searchTerm: ?string
40 * originalEvent: SyntheticEvent,
41 * }
42 * ): void}
43 */
44 onChange: PropTypes.func,
45 open: PropTypes.bool,
46 onToggle: PropTypes.func,
47 data: PropTypes.array,
48 dataKey: CustomPropTypes.accessor,
49 textField: CustomPropTypes.accessor,
50 allowCreate: PropTypes.oneOf([true, false, 'onFilter']),
51
52 /**
53 * A React render prop for customizing the rendering of the DropdownList
54 * value
55 */
56 renderValue: PropTypes.func,
57 renderListItem: PropTypes.func,
58 listComponent: CustomPropTypes.elementType,
59 optionComponent: CustomPropTypes.elementType,
60 renderPopup: PropTypes.func,
61 renderListGroup: PropTypes.func,
62 groupBy: CustomPropTypes.accessor,
63
64 /**
65 *
66 * @type {(dataItem: ?any, metadata: { originalEvent: SyntheticEvent }) => void}
67 */
68 onSelect: PropTypes.func,
69 onCreate: PropTypes.func,
70
71 /**
72 * @type function(searchTerm: string, metadata: { action, lastSearchTerm, originalEvent? })
73 */
74 onSearch: PropTypes.func,
75 searchTerm: PropTypes.string,
76 busy: PropTypes.bool,
77
78 /** Specify the element used to render the select (down arrow) icon. */
79 selectIcon: PropTypes.node,
80
81 /** Specify the element used to render the busy indicator */
82 busySpinner: PropTypes.node,
83 placeholder: PropTypes.string,
84 dropUp: PropTypes.bool,
85 popupTransition: CustomPropTypes.elementType,
86 disabled: CustomPropTypes.disabled.acceptsArray,
87 readOnly: CustomPropTypes.disabled,
88
89 /** Adds a css class to the input container element. */
90 containerClassName: PropTypes.string,
91 inputProps: PropTypes.object,
92 listProps: PropTypes.object,
93 messages: PropTypes.shape({
94 open: PropTypes.string,
95 emptyList: CustomPropTypes.message,
96 emptyFilter: CustomPropTypes.message,
97 createOption: CustomPropTypes.message
98 })
99};
100
101function useSearchWordBuilder(delay) {
102 const timeout = useTimeout();
103 const wordRef = useRef('');
104
105 function search(character, cb) {
106 let word = (wordRef.current + character).toLowerCase();
107 if (!character) return;
108 wordRef.current = word;
109 timeout.set(() => {
110 wordRef.current = '';
111 cb(word);
112 }, delay);
113 }
114
115 return search;
116}
117
118/**
119 * A `<select>` replacement for single value lists.
120 * @public
121 */
122const DropdownListImpl = /*#__PURE__*/React.forwardRef(function DropdownList(_ref, outerRef) {
123 let {
124 id,
125 autoFocus,
126 textField,
127 dataKey,
128 value,
129 defaultValue,
130 onChange,
131 open,
132 defaultOpen = false,
133 onToggle,
134 searchTerm,
135 defaultSearchTerm = '',
136 onSearch,
137 filter = true,
138 allowCreate = false,
139 delay = 500,
140 focusFirstItem,
141 className,
142 containerClassName,
143 placeholder,
144 busy,
145 disabled,
146 readOnly,
147 selectIcon = caretDown,
148 busySpinner,
149 dropUp,
150 tabIndex,
151 popupTransition,
152 name,
153 autoComplete,
154 onSelect,
155 onCreate,
156 onKeyPress,
157 onKeyDown,
158 onClick,
159 inputProps,
160 listProps,
161 renderListItem,
162 renderListGroup,
163 optionComponent,
164 renderValue,
165 groupBy,
166 onBlur,
167 onFocus,
168 listComponent: ListComponent = List,
169 popupComponent: Popup = BasePopup,
170 data: rawData = [],
171 messages: userMessages
172 } = _ref,
173 elementProps = _objectWithoutPropertiesLoose(_ref, _excluded);
174
175 const [currentValue, handleChange] = useUncontrolledProp(value, defaultValue, onChange);
176 const [currentOpen, handleOpen] = useUncontrolledProp(open, defaultOpen, onToggle);
177 const [currentSearch, handleSearch] = useUncontrolledProp(searchTerm, defaultSearchTerm, onSearch);
178 const ref = useRef(null);
179 const filterRef = useRef(null);
180 const listRef = useRef(null);
181 const inputId = useInstanceId(id, '_input');
182 const listId = useInstanceId(id, '_listbox');
183 const activeId = useInstanceId(id, '_listbox_active_option');
184 const accessors = useAccessors(textField, dataKey);
185 const messages = useMessagesWithDefaults(userMessages);
186 useAutoFocus(!!autoFocus, ref);
187 const toggle = useDropdownToggle(currentOpen, handleOpen);
188 const isDisabled = disabled === true; // const disabledItems = toItemArray(disabled)
189
190 const isReadOnly = !!readOnly;
191 const [focusEvents, focused] = useFocusManager(ref, {
192 disabled: isDisabled,
193 onBlur,
194 onFocus
195 }, {
196 didHandle(focused) {
197 if (focused) {
198 if (filter) focus();
199 return;
200 }
201
202 toggle.close();
203 clearSearch();
204 }
205
206 });
207 const data = useFilteredData(rawData, currentOpen ? filter : false, currentSearch, accessors.text);
208 const selectedItem = useMemo(() => data[accessors.indexOf(data, currentValue)], [data, currentValue, accessors]);
209 const list = useFocusList({
210 activeId,
211 scope: ref,
212 focusFirstItem,
213 anchorItem: currentOpen ? selectedItem : undefined
214 });
215 const [autofilling, setAutofilling] = useState(false);
216 const nextSearchChar = useSearchWordBuilder(delay);
217 const focusedItem = list.getFocused();
218 useActiveDescendant(ref, activeId, focusedItem && currentOpen, [focusedItem]);
219 const showCreateOption = canShowCreate(allowCreate, {
220 searchTerm: currentSearch,
221 data,
222 accessors
223 });
224
225 const handleCreate = event => {
226 notify(onCreate, [currentSearch]);
227 clearSearch(event);
228 toggle.close();
229 focus();
230 };
231
232 const handleSelect = (dataItem, originalEvent) => {
233 if (readOnly || isDisabled) return;
234 if (dataItem === undefined) return;
235 originalEvent == null ? void 0 : originalEvent.preventDefault();
236
237 if (dataItem === CREATE_OPTION) {
238 handleCreate(originalEvent);
239 return;
240 }
241
242 notify(onSelect, [dataItem, {
243 originalEvent
244 }]);
245 change(dataItem, originalEvent, true);
246 toggle.close();
247 focus();
248 };
249
250 const handleClick = e => {
251 if (readOnly || isDisabled) return; // prevents double clicks when in a <label>
252
253 e.preventDefault();
254 focus();
255 toggle();
256 notify(onClick, [e]);
257 };
258
259 const handleKeyDown = e => {
260 if (readOnly || isDisabled) return;
261 let {
262 key,
263 altKey,
264 ctrlKey,
265 shiftKey
266 } = e;
267 notify(onKeyDown, [e]);
268
269 let closeWithFocus = () => {
270 clearSearch();
271 toggle.close();
272 if (currentOpen) setTimeout(focus);
273 };
274
275 if (e.defaultPrevented) return;
276
277 if (key === 'End' && currentOpen && !shiftKey) {
278 e.preventDefault();
279 list.focus(list.last());
280 } else if (key === 'Home' && currentOpen && !shiftKey) {
281 e.preventDefault();
282 list.focus(list.first());
283 } else if (key === 'Escape' && (currentOpen || currentSearch)) {
284 e.preventDefault();
285 closeWithFocus();
286 } else if (key === 'Enter' && currentOpen && ctrlKey && showCreateOption) {
287 e.preventDefault();
288 handleCreate(e);
289 } else if ((key === 'Enter' || key === ' ' && !filter) && currentOpen) {
290 e.preventDefault();
291 if (list.hasFocused()) handleSelect(list.getFocused(), e);
292 } else if (key === 'ArrowDown') {
293 e.preventDefault();
294
295 if (!currentOpen) {
296 toggle.open();
297 return;
298 }
299
300 list.focus(list.next());
301 } else if (key === 'ArrowUp') {
302 e.preventDefault();
303 if (altKey) return closeWithFocus();
304 list.focus(list.prev());
305 }
306 };
307
308 const handleKeyPress = e => {
309 if (readOnly || isDisabled) return;
310 notify(onKeyPress, [e]);
311 if (e.defaultPrevented || filter) return;
312 nextSearchChar(String.fromCharCode(e.which), word => {
313 if (!currentOpen) return;
314
315 let isValid = item => presets.startsWith(accessors.text(item).toLowerCase(), word.toLowerCase());
316
317 const [items, focusedItem] = list.get();
318 const len = items.length;
319 const startIdx = items.indexOf(focusedItem) + 1;
320 const offset = startIdx >= len ? 0 : startIdx;
321 let idx = 0;
322 let pointer = offset;
323
324 while (idx < len) {
325 pointer = (idx + offset) % len;
326 let item = items[pointer];
327 if (isValid(list.toDataItem(item))) break;
328 idx++;
329 }
330
331 if (idx === len) return;
332 list.focus(items[pointer]);
333 });
334 };
335
336 const handleInputChange = e => {
337 // hitting space to open
338 if (!currentOpen && !e.target.value.trim()) {
339 e.preventDefault();
340 } else {
341 search(e.target.value, e, 'input');
342 }
343
344 toggle.open();
345 };
346
347 const handleAutofillChange = e => {
348 let filledValue = e.target.value.toLowerCase();
349 if (filledValue === '') return void change(null);
350
351 for (const item of rawData) {
352 if (String(accessors.value(item)).toLowerCase() === filledValue || accessors.text(item).toLowerCase() === filledValue) {
353 change(item, e);
354 break;
355 }
356 }
357 };
358
359 function change(nextValue, originalEvent, selected = false) {
360 if (!accessors.matches(nextValue, currentValue)) {
361 notify(handleChange, [nextValue, {
362 originalEvent,
363 source: selected ? 'listbox' : 'input',
364 lastValue: currentValue,
365 searchTerm: currentSearch
366 }]);
367 clearSearch(originalEvent);
368 toggle.close();
369 }
370 }
371
372 function focus() {
373 if (filter) filterRef.current.focus();else ref.current.focus();
374 }
375
376 function clearSearch(originalEvent) {
377 search('', originalEvent, 'clear');
378 }
379
380 function search(nextSearchTerm, originalEvent, action = 'input') {
381 if (currentSearch !== nextSearchTerm) handleSearch(nextSearchTerm, {
382 action,
383 originalEvent,
384 lastSearchTerm: currentSearch
385 });
386 }
387 /**
388 * Render
389 */
390
391
392 useImperativeHandle(outerRef, () => ({
393 focus
394 }));
395 let valueItem = accessors.findOrSelf(data, currentValue);
396 let shouldRenderPopup = useFirstFocusedRender(focused, currentOpen);
397 const widgetProps = Object.assign({}, elementProps, {
398 role: 'combobox',
399 id: inputId,
400 //tab index when there is no filter input to take focus
401 tabIndex: filter ? -1 : tabIndex || 0,
402 // FIXME: only when item exists
403 'aria-owns': listId,
404 'aria-expanded': !!currentOpen,
405 'aria-haspopup': true,
406 'aria-busy': !!busy,
407 'aria-live': currentOpen ? 'polite' : undefined,
408 'aria-autocomplete': 'list',
409 'aria-disabled': isDisabled,
410 'aria-readonly': isReadOnly
411 });
412 return /*#__PURE__*/React.createElement(FocusListContext.Provider, {
413 value: list.context
414 }, /*#__PURE__*/React.createElement(Widget, _extends({}, widgetProps, {
415 open: !!currentOpen,
416 dropUp: !!dropUp,
417 focused: !!focused,
418 disabled: isDisabled,
419 readOnly: isReadOnly,
420 autofilling: autofilling
421 }, focusEvents, {
422 onKeyDown: handleKeyDown,
423 onKeyPress: handleKeyPress,
424 className: cn(className, 'rw-dropdown-list'),
425 ref: ref
426 }), /*#__PURE__*/React.createElement(WidgetPicker, {
427 onClick: handleClick,
428 tabIndex: filter ? -1 : 0,
429 className: cn(containerClassName, 'rw-widget-input')
430 }, /*#__PURE__*/React.createElement(DropdownListInput, _extends({}, inputProps, {
431 value: valueItem,
432 dataKeyAccessor: accessors.value,
433 textAccessor: accessors.text,
434 name: name,
435 readOnly: readOnly,
436 disabled: isDisabled,
437 allowSearch: !!filter,
438 searchTerm: currentSearch,
439 ref: filterRef,
440 autoComplete: autoComplete,
441 onSearch: handleInputChange,
442 onAutofill: setAutofilling,
443 onAutofillChange: handleAutofillChange,
444 placeholder: placeholder,
445 renderValue: renderValue
446 })), /*#__PURE__*/React.createElement(PickerCaret, {
447 visible: true,
448 busy: busy,
449 icon: selectIcon,
450 spinner: busySpinner
451 })), shouldRenderPopup && /*#__PURE__*/React.createElement(Popup, {
452 dropUp: dropUp,
453 open: currentOpen,
454 transition: popupTransition,
455 onEntered: focus,
456 onEntering: () => listRef.current.scrollIntoView()
457 }, /*#__PURE__*/React.createElement(ListComponent, _extends({}, listProps, {
458 id: listId,
459 data: data,
460 tabIndex: -1,
461 disabled: disabled,
462 groupBy: groupBy,
463 searchTerm: currentSearch,
464 accessors: accessors,
465 renderItem: renderListItem,
466 renderGroup: renderListGroup,
467 optionComponent: optionComponent,
468 value: selectedItem,
469 onChange: (d, meta) => handleSelect(d, meta.originalEvent),
470 "aria-live": currentOpen ? 'polite' : undefined,
471 "aria-labelledby": inputId,
472 "aria-hidden": !currentOpen,
473 ref: listRef,
474 messages: {
475 emptyList: rawData.length ? messages.emptyFilter : messages.emptyList
476 }
477 })), showCreateOption && /*#__PURE__*/React.createElement(AddToListOption, {
478 onSelect: handleCreate
479 }, messages.createOption(currentValue, currentSearch || '')))));
480});
481DropdownListImpl.displayName = 'DropdownList';
482DropdownListImpl.propTypes = propTypes;
483export default DropdownListImpl;
\No newline at end of file