1 | const _excluded = ["dataKey", "textField", "autoFocus", "id", "value", "defaultValue", "onChange", "open", "defaultOpen", "onToggle", "focusFirstItem", "searchTerm", "defaultSearchTerm", "onSearch", "filter", "allowCreate", "className", "containerClassName", "placeholder", "busy", "disabled", "readOnly", "selectIcon", "clearTagIcon", "busySpinner", "dropUp", "tabIndex", "popupTransition", "showPlaceholderWithValues", "showSelectedItemsInList", "onSelect", "onCreate", "onKeyDown", "onBlur", "onFocus", "inputProps", "listProps", "renderListItem", "renderListGroup", "renderTagValue", "optionComponent", "tagOptionComponent", "groupBy", "listComponent", "popupComponent", "data", "messages"];
|
2 |
|
3 | function _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 |
|
5 | function _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 |
|
7 | import cn from 'classnames';
|
8 | import closest from 'dom-helpers/closest';
|
9 | import PropTypes from 'prop-types';
|
10 | import React, { useImperativeHandle, useMemo, useRef, useEffect } from 'react';
|
11 | import { useUncontrolledProp } from 'uncontrollable';
|
12 | import AddToListOption, { CREATE_OPTION } from './AddToListOption';
|
13 | import { times } from './Icon';
|
14 | import List from './List';
|
15 | import { FocusListContext, useFocusList } from './FocusListContext';
|
16 | import MultiselectInput from './MultiselectInput';
|
17 | import TagList from './MultiselectTagList';
|
18 | import BasePopup from './Popup';
|
19 | import Widget from './Widget';
|
20 | import WidgetPicker from './WidgetPicker';
|
21 | import { useMessagesWithDefaults } from './messages';
|
22 | import { setActiveDescendant } from './A11y';
|
23 | import { useFilteredData } from './Filter';
|
24 | import * as CustomPropTypes from './PropTypes';
|
25 | import canShowCreate from './canShowCreate';
|
26 | import { useAccessors } from './Accessors';
|
27 | import useDropdownToggle from './useDropdownToggle';
|
28 | import useFocusManager from './useFocusManager';
|
29 | import { notify, useFirstFocusedRender, useInstanceId } from './WidgetHelpers';
|
30 | import DropdownCaret from './PickerCaret';
|
31 | const ENTER = 13;
|
32 | const INSERT = 'insert';
|
33 | const REMOVE = 'remove';
|
34 | let propTypes = {
|
35 | data: PropTypes.array,
|
36 |
|
37 | value: PropTypes.array,
|
38 |
|
39 | |
40 |
|
41 |
|
42 |
|
43 |
|
44 |
|
45 |
|
46 |
|
47 |
|
48 |
|
49 |
|
50 |
|
51 | onChange: PropTypes.func,
|
52 | searchTerm: PropTypes.string,
|
53 |
|
54 | |
55 |
|
56 |
|
57 |
|
58 |
|
59 |
|
60 |
|
61 |
|
62 |
|
63 |
|
64 | onSearch: PropTypes.func,
|
65 | open: PropTypes.bool,
|
66 | handleOpen: PropTypes.func,
|
67 |
|
68 | dataKey: CustomPropTypes.accessor,
|
69 | textField: CustomPropTypes.accessor,
|
70 | renderTagValue: PropTypes.func,
|
71 | renderListItem: PropTypes.func,
|
72 | renderListGroup: PropTypes.func,
|
73 | groupBy: CustomPropTypes.accessor,
|
74 | allowCreate: PropTypes.oneOf([true, false, 'onFilter']),
|
75 |
|
76 | |
77 |
|
78 |
|
79 |
|
80 | onSelect: PropTypes.func,
|
81 |
|
82 | |
83 |
|
84 |
|
85 | onCreate: PropTypes.func,
|
86 | busy: PropTypes.bool,
|
87 |
|
88 |
|
89 | selectIcon: PropTypes.node,
|
90 |
|
91 |
|
92 | clearTagIcon: PropTypes.node,
|
93 |
|
94 |
|
95 | busySpinner: PropTypes.node,
|
96 | dropUp: PropTypes.bool,
|
97 | popupTransition: PropTypes.elementType,
|
98 |
|
99 |
|
100 | containerClassName: PropTypes.string,
|
101 | inputProps: PropTypes.object,
|
102 | listProps: PropTypes.object,
|
103 | autoFocus: PropTypes.bool,
|
104 | placeholder: PropTypes.string,
|
105 |
|
106 |
|
107 | showPlaceholderWithValues: PropTypes.bool,
|
108 |
|
109 |
|
110 | showSelectedItemsInList: PropTypes.bool,
|
111 | disabled: CustomPropTypes.disabled.acceptsArray,
|
112 | readOnly: CustomPropTypes.disabled,
|
113 | messages: PropTypes.shape({
|
114 | open: CustomPropTypes.message,
|
115 | emptyList: CustomPropTypes.message,
|
116 | emptyFilter: CustomPropTypes.message,
|
117 | createOption: CustomPropTypes.message,
|
118 | tagsLabel: CustomPropTypes.message,
|
119 | selectedItems: CustomPropTypes.message,
|
120 | noneSelected: CustomPropTypes.message,
|
121 | removeLabel: CustomPropTypes.message
|
122 | })
|
123 | };
|
124 | const EMPTY_ARRAY = [];
|
125 |
|
126 | function useMultiselectData(value = EMPTY_ARRAY, data, accessors, filter, searchTerm, showSelectedItemsInList) {
|
127 | data = useMemo(() => showSelectedItemsInList ? data : data.filter(i => !value.some(v => accessors.matches(i, v))), [data, showSelectedItemsInList, value, accessors]);
|
128 | return [useFilteredData(data, filter || false, searchTerm, accessors.text), data.length];
|
129 | }
|
130 |
|
131 |
|
132 |
|
133 |
|
134 |
|
135 |
|
136 |
|
137 |
|
138 |
|
139 |
|
140 |
|
141 |
|
142 |
|
143 |
|
144 |
|
145 |
|
146 |
|
147 |
|
148 |
|
149 |
|
150 |
|
151 |
|
152 | const Multiselect = React.forwardRef(function Multiselect(_ref, outerRef) {
|
153 | let {
|
154 | dataKey,
|
155 | textField,
|
156 | autoFocus,
|
157 | id,
|
158 | value,
|
159 | defaultValue = [],
|
160 | onChange,
|
161 | open,
|
162 | defaultOpen = false,
|
163 | onToggle,
|
164 | focusFirstItem = false,
|
165 | searchTerm,
|
166 | defaultSearchTerm = '',
|
167 | onSearch,
|
168 | filter = 'startsWith',
|
169 | allowCreate = false,
|
170 | className,
|
171 | containerClassName,
|
172 | placeholder,
|
173 | busy,
|
174 | disabled,
|
175 | readOnly,
|
176 | selectIcon,
|
177 | clearTagIcon = times,
|
178 | busySpinner,
|
179 | dropUp,
|
180 | tabIndex,
|
181 | popupTransition,
|
182 | showPlaceholderWithValues = false,
|
183 | showSelectedItemsInList = false,
|
184 | onSelect,
|
185 | onCreate,
|
186 | onKeyDown,
|
187 | onBlur,
|
188 | onFocus,
|
189 | inputProps,
|
190 | listProps,
|
191 | renderListItem,
|
192 | renderListGroup,
|
193 | renderTagValue,
|
194 | optionComponent,
|
195 | tagOptionComponent,
|
196 | groupBy,
|
197 | listComponent: ListComponent = List,
|
198 | popupComponent: Popup = BasePopup,
|
199 | data: rawData = [],
|
200 | messages: userMessages
|
201 | } = _ref,
|
202 | elementProps = _objectWithoutPropertiesLoose(_ref, _excluded);
|
203 |
|
204 | let [currentValue, handleChange] = useUncontrolledProp(value, defaultValue, onChange);
|
205 | const [currentOpen, handleOpen] = useUncontrolledProp(open, defaultOpen, onToggle);
|
206 | const [currentSearch, handleSearch] = useUncontrolledProp(searchTerm, defaultSearchTerm, onSearch);
|
207 | const ref = useRef(null);
|
208 | const inputRef = useRef(null);
|
209 | const listRef = useRef(null);
|
210 | const inputId = useInstanceId(id, '_input');
|
211 | const tagsId = useInstanceId(id, '_taglist');
|
212 | const listId = useInstanceId(id, '_listbox');
|
213 | const createId = useInstanceId(id, '_createlist_option');
|
214 | const activeTagId = useInstanceId(id, '_taglist_active_tag');
|
215 | const activeOptionId = useInstanceId(id, '_listbox_active_option');
|
216 | const accessors = useAccessors(textField, dataKey);
|
217 | const messages = useMessagesWithDefaults(userMessages);
|
218 | const toggle = useDropdownToggle(currentOpen, handleOpen);
|
219 | const isDisabled = disabled === true;
|
220 | const isReadOnly = !!readOnly;
|
221 | const [focusEvents, focused] = useFocusManager(ref, {
|
222 | disabled: isDisabled,
|
223 | onBlur,
|
224 | onFocus
|
225 | }, {
|
226 | didHandle(focused, event) {
|
227 | if (focused) return focus();
|
228 | toggle.close();
|
229 | clearSearch(event);
|
230 | tagList.focus(null);
|
231 | }
|
232 |
|
233 | });
|
234 | const dataItems = useMemo(() => currentValue.map(item => accessors.findOrSelf(rawData, item)), [rawData, currentValue, accessors]);
|
235 | const [data, lengthWithoutValues] = useMultiselectData(dataItems, rawData, accessors, currentOpen ? filter : false, currentSearch, showSelectedItemsInList);
|
236 | const list = useFocusList({
|
237 | scope: ref,
|
238 | scopeSelector: '.rw-popup',
|
239 | focusFirstItem,
|
240 | activeId: activeOptionId,
|
241 | anchorItem: currentOpen ? dataItems[dataItems.length - 1] : undefined
|
242 | });
|
243 | const tagList = useFocusList({
|
244 | scope: ref,
|
245 | scopeSelector: '.rw-multiselect-taglist',
|
246 | activeId: activeTagId
|
247 | });
|
248 | const showCreateOption = canShowCreate(allowCreate, {
|
249 | searchTerm: currentSearch,
|
250 | data,
|
251 | dataItems,
|
252 | accessors
|
253 | });
|
254 | |
255 |
|
256 |
|
257 |
|
258 | const focusedTag = tagList.getFocused();
|
259 | useEffect(() => {
|
260 | if (currentOpen) return;
|
261 | setActiveDescendant(inputRef.current, focusedTag ? activeTagId : '');
|
262 | }, [activeTagId, currentOpen, focusedTag]);
|
263 | const focusedItem = list.getFocused();
|
264 | useEffect(() => {
|
265 | if (!currentOpen) return;
|
266 |
|
267 | setActiveDescendant(inputRef.current, focusedItem ? activeOptionId : '');
|
268 | }, [activeOptionId, currentOpen, focusedItem]);
|
269 | |
270 |
|
271 |
|
272 |
|
273 | const handleDelete = (dataItem, event) => {
|
274 | if (isDisabled || readOnly || tagList.size() === 0) return;
|
275 | focus();
|
276 | change(dataItem, event, REMOVE);
|
277 | };
|
278 |
|
279 | const deletingRef = useRef(false);
|
280 |
|
281 | const handleSearchKeyDown = e => {
|
282 | if (e.key === 'Backspace' && e.currentTarget.value && !deletingRef.current) deletingRef.current = true;
|
283 | };
|
284 |
|
285 | const handleSearchKeyUp = e => {
|
286 | if (e.key === 'Backspace' && deletingRef.current) {
|
287 | deletingRef.current = false;
|
288 | }
|
289 | };
|
290 |
|
291 | const handleInputChange = e => {
|
292 | search(e.target.value, e, 'input');
|
293 | toggle.open();
|
294 | };
|
295 |
|
296 | const handleClick = e => {
|
297 | if (isDisabled || readOnly) return;
|
298 |
|
299 | e.preventDefault();
|
300 | focus();
|
301 |
|
302 | if (closest(e.target, '.rw-select') && currentOpen) {
|
303 | toggle.close();
|
304 | } else toggle.open();
|
305 | };
|
306 |
|
307 | const handleDoubleClick = () => {
|
308 | if (isDisabled || !inputRef.current) return;
|
309 | focus();
|
310 | if (inputRef.current) inputRef.current.select();
|
311 | };
|
312 |
|
313 | const handleSelect = (dataItem, originalEvent) => {
|
314 | if (dataItem === undefined) return;
|
315 | originalEvent.preventDefault();
|
316 |
|
317 | if (dataItem === CREATE_OPTION) {
|
318 | handleCreate(originalEvent);
|
319 | return;
|
320 | }
|
321 |
|
322 | notify(onSelect, [dataItem, {
|
323 | originalEvent
|
324 | }]);
|
325 |
|
326 | if (!showSelectedItemsInList || !dataItems.includes(dataItem)) {
|
327 | change(dataItem, originalEvent, INSERT);
|
328 | } else {
|
329 | change(dataItem, originalEvent, REMOVE);
|
330 | }
|
331 |
|
332 | focus();
|
333 | };
|
334 |
|
335 | const handleCreate = event => {
|
336 | notify(onCreate, [currentSearch]);
|
337 | clearSearch(event);
|
338 | focus();
|
339 | };
|
340 |
|
341 | const handleKeyDown = event => {
|
342 | if (readOnly) {
|
343 | event.preventDefault();
|
344 | return;
|
345 | }
|
346 |
|
347 | let {
|
348 | key,
|
349 | keyCode,
|
350 | altKey,
|
351 | ctrlKey
|
352 | } = event;
|
353 | notify(onKeyDown, [event]);
|
354 | if (event.defaultPrevented) return;
|
355 |
|
356 | if (key === 'ArrowDown') {
|
357 | event.preventDefault();
|
358 |
|
359 | if (!currentOpen) {
|
360 | toggle.open();
|
361 | return;
|
362 | }
|
363 |
|
364 | list.focus(list.next());
|
365 | tagList.focus(null);
|
366 | } else if (key === 'ArrowUp' && (currentOpen || altKey)) {
|
367 | event.preventDefault();
|
368 |
|
369 | if (altKey) {
|
370 | toggle.close();
|
371 | return;
|
372 | }
|
373 |
|
374 | list.focus(list.prev());
|
375 | tagList.focus(null);
|
376 | } else if (key === 'End') {
|
377 | event.preventDefault();
|
378 |
|
379 | if (currentOpen) {
|
380 | list.focus(list.last());
|
381 | tagList.focus(null);
|
382 | } else {
|
383 | tagList.focus(tagList.last());
|
384 | list.focus(null);
|
385 | }
|
386 | } else if (key === 'Home') {
|
387 | event.preventDefault();
|
388 | if (currentOpen) list.focus(list.first());else list.focus(tagList.first());
|
389 | } else if (currentOpen && keyCode === ENTER) {
|
390 |
|
391 | event.preventDefault();
|
392 |
|
393 | if (ctrlKey && showCreateOption) {
|
394 | return handleCreate(event);
|
395 | }
|
396 |
|
397 | handleSelect(list.getFocused(), event);
|
398 | } else if (key === 'Escape') {
|
399 | if (currentOpen) toggle.close();else tagList.focus(null);
|
400 | } else if (!currentSearch && !deletingRef.current) {
|
401 |
|
402 | if (key === 'ArrowLeft') {
|
403 | tagList.focus(tagList.prev({
|
404 | behavior: 'loop'
|
405 | }));
|
406 | } else if (key === 'ArrowRight') {
|
407 | tagList.focus(tagList.next({
|
408 | behavior: 'loop'
|
409 | }));
|
410 | } else if (key === 'Delete' && tagList.getFocused()) {
|
411 | handleDelete(tagList.getFocused(), event);
|
412 | } else if (key === 'Backspace') {
|
413 | handleDelete(tagList.toDataItem(tagList.last()), event);
|
414 | } else if (key === ' ' && !currentOpen) {
|
415 | event.preventDefault();
|
416 | toggle.open();
|
417 | }
|
418 | }
|
419 | };
|
420 | |
421 |
|
422 |
|
423 |
|
424 |
|
425 | function change(dataItem, originalEvent, action) {
|
426 | let nextDataItems = dataItems;
|
427 |
|
428 | switch (action) {
|
429 | case INSERT:
|
430 | nextDataItems = nextDataItems.concat(dataItem);
|
431 | break;
|
432 |
|
433 | case REMOVE:
|
434 | nextDataItems = nextDataItems.filter(d => d !== dataItem);
|
435 | break;
|
436 | }
|
437 |
|
438 | handleChange(nextDataItems, {
|
439 | action,
|
440 | dataItem,
|
441 | originalEvent,
|
442 | searchTerm: currentSearch,
|
443 | lastValue: currentValue
|
444 | });
|
445 | clearSearch(originalEvent);
|
446 | }
|
447 |
|
448 | function clearSearch(originalEvent) {
|
449 | search('', originalEvent, 'clear');
|
450 | }
|
451 |
|
452 | function search(nextSearchTerm, originalEvent, action = 'input') {
|
453 | if (nextSearchTerm !== currentSearch) handleSearch(nextSearchTerm, {
|
454 | action,
|
455 | originalEvent,
|
456 | lastSearchTerm: currentSearch
|
457 | });
|
458 | }
|
459 |
|
460 | function focus() {
|
461 | if (inputRef.current) inputRef.current.focus();
|
462 | }
|
463 | |
464 |
|
465 |
|
466 |
|
467 |
|
468 | useImperativeHandle(outerRef, () => ({
|
469 | focus
|
470 | }));
|
471 | let shouldRenderPopup = useFirstFocusedRender(focused, currentOpen);
|
472 | let shouldRenderTags = !!dataItems.length;
|
473 | let inputOwns = `${listId} ` + (shouldRenderTags ? tagsId : '') + (showCreateOption ? createId : '');
|
474 | return React.createElement(Widget, _extends({}, elementProps, {
|
475 | ref: ref,
|
476 | open: currentOpen,
|
477 | dropUp: dropUp,
|
478 | focused: focused,
|
479 | disabled: isDisabled,
|
480 | readOnly: isReadOnly,
|
481 | onKeyDown: handleKeyDown
|
482 | }, focusEvents, {
|
483 | className: cn(className, 'rw-multiselect')
|
484 | }), React.createElement(WidgetPicker, {
|
485 | onClick: handleClick,
|
486 | onTouchEnd: handleClick,
|
487 | onDoubleClick: handleDoubleClick,
|
488 | className: cn(containerClassName, 'rw-widget-input')
|
489 | }, React.createElement(FocusListContext.Provider, {
|
490 | value: tagList.context
|
491 | }, React.createElement(TagList, {
|
492 | id: tagsId,
|
493 | textAccessor: accessors.text,
|
494 | clearTagIcon: clearTagIcon,
|
495 | label: messages.tagsLabel(),
|
496 | value: dataItems,
|
497 | readOnly: isReadOnly,
|
498 | disabled: disabled,
|
499 | onDelete: handleDelete,
|
500 | tagOptionComponent: tagOptionComponent,
|
501 | renderTagValue: renderTagValue
|
502 | }, React.createElement(MultiselectInput, _extends({}, inputProps, {
|
503 | role: "combobox",
|
504 | autoFocus: autoFocus,
|
505 | tabIndex: tabIndex || 0,
|
506 | "aria-expanded": !!currentOpen,
|
507 | "aria-busy": !!busy,
|
508 | "aria-owns": inputOwns,
|
509 | "aria-controls": listId,
|
510 | "aria-haspopup": "listbox",
|
511 | "aria-autocomplete": "list",
|
512 | value: currentSearch,
|
513 | disabled: isDisabled,
|
514 | readOnly: isReadOnly,
|
515 | placeholder: (currentValue.length && !showPlaceholderWithValues ? '' : placeholder) || '',
|
516 | onKeyDown: handleSearchKeyDown,
|
517 | onKeyUp: handleSearchKeyUp,
|
518 | onChange: handleInputChange,
|
519 | ref: inputRef
|
520 | })))), React.createElement(DropdownCaret, {
|
521 | busy: busy,
|
522 | spinner: busySpinner,
|
523 | icon: selectIcon,
|
524 | visible: focused
|
525 | })), React.createElement(FocusListContext.Provider, {
|
526 | value: list.context
|
527 | }, shouldRenderPopup && React.createElement(Popup, {
|
528 | dropUp: dropUp,
|
529 | open: currentOpen,
|
530 | transition: popupTransition,
|
531 | onEntering: () => listRef.current.scrollIntoView()
|
532 | }, React.createElement(ListComponent, _extends({}, listProps, {
|
533 | id: listId,
|
534 | data: data,
|
535 | tabIndex: -1,
|
536 | disabled: disabled,
|
537 | searchTerm: currentSearch,
|
538 | accessors: accessors,
|
539 | renderItem: renderListItem,
|
540 | renderGroup: renderListGroup,
|
541 | value: dataItems,
|
542 | groupBy: groupBy,
|
543 | optionComponent: optionComponent,
|
544 | onChange: (d, meta) => handleSelect(d, meta.originalEvent),
|
545 | "aria-live": "polite",
|
546 | "aria-labelledby": inputId,
|
547 | "aria-hidden": !currentOpen,
|
548 | ref: listRef,
|
549 | messages: {
|
550 | emptyList: lengthWithoutValues ? messages.emptyFilter : messages.emptyList
|
551 | }
|
552 | })), showCreateOption && React.createElement(AddToListOption, {
|
553 | onSelect: handleCreate
|
554 | }, messages.createOption(currentValue, currentSearch)))));
|
555 | });
|
556 | Multiselect.displayName = 'Multiselect';
|
557 | Multiselect.propTypes = propTypes;
|
558 | export default Multiselect; |
\ | No newline at end of file |