UNPKG

17.7 kBJavaScriptView Raw
1const _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
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 closest from 'dom-helpers/closest';
9import PropTypes from 'prop-types';
10import React, { useImperativeHandle, useMemo, useRef, useEffect } from 'react';
11import { useUncontrolledProp } from 'uncontrollable';
12import AddToListOption, { CREATE_OPTION } from './AddToListOption';
13import { times } from './Icon';
14import List from './List';
15import { FocusListContext, useFocusList } from './FocusListContext';
16import MultiselectInput from './MultiselectInput';
17import TagList from './MultiselectTagList';
18import BasePopup from './Popup';
19import Widget from './Widget';
20import WidgetPicker from './WidgetPicker';
21import { useMessagesWithDefaults } from './messages';
22import { setActiveDescendant } from './A11y';
23import { useFilteredData } from './Filter';
24import * as CustomPropTypes from './PropTypes';
25import canShowCreate from './canShowCreate';
26import { useAccessors } from './Accessors';
27import useDropdownToggle from './useDropdownToggle';
28import useFocusManager from './useFocusManager';
29import { notify, useFirstFocusedRender, useInstanceId } from './WidgetHelpers';
30import DropdownCaret from './PickerCaret';
31const ENTER = 13;
32const INSERT = 'insert';
33const REMOVE = 'remove';
34let propTypes = {
35 data: PropTypes.array,
36 //-- controlled props --
37 value: PropTypes.array,
38
39 /**
40 * @type {function (
41 * dataItems: ?any[],
42 * metadata: {
43 * dataItem: any,
44 * action: 'insert' | 'remove',
45 * originalEvent: SyntheticEvent,
46 * lastValue: ?any[],
47 * searchTerm: ?string
48 * }
49 * ): void}
50 */
51 onChange: PropTypes.func,
52 searchTerm: PropTypes.string,
53
54 /**
55 * @type {function (
56 * searchTerm: ?string,
57 * metadata: {
58 * action: 'clear' | 'input',
59 * lastSearchTerm: ?string,
60 * originalEvent: SyntheticEvent,
61 * }
62 * ): void}
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 * @type { (dataItem: ?any, metadata: { originalEvent: SyntheticEvent }) => void }
79 */
80 onSelect: PropTypes.func,
81
82 /**
83 * @type { (searchTerm: string) => void }
84 */
85 onCreate: PropTypes.func,
86 busy: PropTypes.bool,
87
88 /** Specify the element used to render the select (down arrow) icon. */
89 selectIcon: PropTypes.node,
90
91 /** Specify the element used to render tag clear icons. */
92 clearTagIcon: PropTypes.node,
93
94 /** Specify the element used to render the busy indicator */
95 busySpinner: PropTypes.node,
96 dropUp: PropTypes.bool,
97 popupTransition: PropTypes.elementType,
98
99 /** Adds a css class to the input container element. */
100 containerClassName: PropTypes.string,
101 inputProps: PropTypes.object,
102 listProps: PropTypes.object,
103 autoFocus: PropTypes.bool,
104 placeholder: PropTypes.string,
105
106 /** Continue to show the input placeholder even if tags are selected */
107 showPlaceholderWithValues: PropTypes.bool,
108
109 /** Continue to show the selected items in the dropdown list */
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};
124const EMPTY_ARRAY = [];
125
126function 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 * shortcuts:
134 * - { key: left arrow, label: move focus to previous tag }
135 * - { key: right arrow, label: move focus to next tag }
136 * - { key: delete, deselect focused tag }
137 * - { key: backspace, deselect next tag }
138 * - { key: alt + up arrow, label: close Multiselect }
139 * - { key: down arrow, label: open Multiselect, and move focus to next item }
140 * - { key: up arrow, label: move focus to previous item }
141 * - { key: home, label: move focus to first item }
142 * - { key: end, label: move focus to last item }
143 * - { key: enter, label: select focused item }
144 * - { key: ctrl + enter, label: create new tag from current searchTerm }
145 * - { key: any key, label: search list for item starting with key }
146 * ---
147 *
148 * A select listbox alternative.
149 *
150 * @public
151 */
152const Multiselect = /*#__PURE__*/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 * Update aria when it changes on update
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; // if (focusedItem) tagList.focus(null)
266
267 setActiveDescendant(inputRef.current, focusedItem ? activeOptionId : '');
268 }, [activeOptionId, currentOpen, focusedItem]);
269 /**
270 * Event Handlers
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; // prevents double clicks when in a <label>
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 // using keyCode to ignore enter for japanese IME
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 * Methods
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 * Render
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 /*#__PURE__*/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 }), /*#__PURE__*/React.createElement(WidgetPicker, {
485 onClick: handleClick,
486 onTouchEnd: handleClick,
487 onDoubleClick: handleDoubleClick,
488 className: cn(containerClassName, 'rw-widget-input')
489 }, /*#__PURE__*/React.createElement(FocusListContext.Provider, {
490 value: tagList.context
491 }, /*#__PURE__*/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 }, /*#__PURE__*/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 })))), /*#__PURE__*/React.createElement(DropdownCaret, {
521 busy: busy,
522 spinner: busySpinner,
523 icon: selectIcon,
524 visible: focused
525 })), /*#__PURE__*/React.createElement(FocusListContext.Provider, {
526 value: list.context
527 }, shouldRenderPopup && /*#__PURE__*/React.createElement(Popup, {
528 dropUp: dropUp,
529 open: currentOpen,
530 transition: popupTransition,
531 onEntering: () => listRef.current.scrollIntoView()
532 }, /*#__PURE__*/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 && /*#__PURE__*/React.createElement(AddToListOption, {
553 onSelect: handleCreate
554 }, messages.createOption(currentValue, currentSearch)))));
555});
556Multiselect.displayName = 'Multiselect';
557Multiselect.propTypes = propTypes;
558export default Multiselect;
\No newline at end of file