UNPKG

12.8 kBJavaScriptView Raw
1const _excluded = ["id", "className", "containerClassName", "placeholder", "autoFocus", "textField", "dataKey", "autoSelectMatches", "focusFirstItem", "value", "defaultValue", "onChange", "open", "defaultOpen", "onToggle", "filter", "busy", "disabled", "readOnly", "selectIcon", "hideCaret", "hideEmptyPopup", "busySpinner", "dropUp", "tabIndex", "popupTransition", "name", "onSelect", "onKeyDown", "onBlur", "onFocus", "inputProps", "listProps", "groupBy", "renderListItem", "renderListGroup", "optionComponent", "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 * as PropTypes from 'prop-types';
9import * as React from 'react';
10import { useImperativeHandle, useMemo, useRef, useState } from 'react';
11import { useUncontrolledProp } from 'uncontrollable';
12import { caretDown } from './Icon';
13import Input from './Input';
14import List from './List';
15import { FocusListContext, useFocusList } from './FocusListContext';
16import BasePopup from './Popup';
17import InputAddon from './InputAddon';
18import Widget from './Widget';
19import WidgetPicker from './WidgetPicker';
20import { useMessagesWithDefaults } from './messages';
21import { useActiveDescendant } from './A11y';
22import * as CustomPropTypes from './PropTypes';
23import { useAccessors } from './Accessors';
24import { useFilteredData } from './Filter';
25import useDropdownToggle from './useDropdownToggle';
26import useFocusManager from './useFocusManager';
27import { notify, useFirstFocusedRender, useInstanceId } from './WidgetHelpers';
28import { Spinner } from './Icon';
29
30function indexOf(data, searchTerm, text) {
31 if (!searchTerm.trim()) return -1;
32
33 for (let idx = 0; idx < data.length; idx++) if (text(data[idx]).toLowerCase() === searchTerm) return idx;
34
35 return -1;
36}
37
38let propTypes = {
39 value: PropTypes.any,
40 onChange: PropTypes.func,
41 open: PropTypes.bool,
42 onToggle: PropTypes.func,
43 renderListItem: PropTypes.func,
44 listComponent: PropTypes.elementType,
45 renderListGroup: PropTypes.func,
46 groupBy: CustomPropTypes.accessor,
47 data: PropTypes.array,
48 dataKey: CustomPropTypes.accessor,
49 textField: CustomPropTypes.accessor,
50 name: PropTypes.string,
51
52 /** Do not show the auto complete list when it returns no results. */
53 hideEmptyPopup: PropTypes.bool,
54
55 /** Hide the combobox dropdown indicator. */
56 hideCaret: PropTypes.bool,
57
58 /**
59 *
60 * @type {(dataItem: ?any, metadata: { originalEvent: SyntheticEvent }) => void}
61 */
62 onSelect: PropTypes.func,
63 autoFocus: PropTypes.bool,
64 disabled: CustomPropTypes.disabled.acceptsArray,
65 readOnly: CustomPropTypes.disabled,
66 busy: PropTypes.bool,
67
68 /** Specify the element used to render the select (down arrow) icon. */
69 selectIcon: PropTypes.node,
70
71 /** Specify the element used to render the busy indicator */
72 busySpinner: PropTypes.node,
73 dropUp: PropTypes.bool,
74 popupTransition: PropTypes.elementType,
75 placeholder: PropTypes.string,
76
77 /** Adds a css class to the input container element. */
78 containerClassName: PropTypes.string,
79 inputProps: PropTypes.object,
80 listProps: PropTypes.object,
81 messages: PropTypes.shape({
82 openCombobox: CustomPropTypes.message,
83 emptyList: CustomPropTypes.message,
84 emptyFilter: CustomPropTypes.message
85 })
86};
87
88/**
89 * ---
90 * shortcuts:
91 * - { key: alt + down arrow, label: open combobox }
92 * - { key: alt + up arrow, label: close combobox }
93 * - { key: down arrow, label: move focus to next item }
94 * - { key: up arrow, label: move focus to previous item }
95 * - { key: home, label: move focus to first item }
96 * - { key: end, label: move focus to last item }
97 * - { key: enter, label: select focused item }
98 * - { key: any key, label: search list for item starting with key }
99 * ---
100 *
101 * Select an item from the list, or input a custom value. The Combobox can also make suggestions as you type.
102
103 * @public
104 */
105const ComboboxImpl = /*#__PURE__*/React.forwardRef(function Combobox(_ref, outerRef) {
106 let {
107 id,
108 className,
109 containerClassName,
110 placeholder,
111 autoFocus,
112 textField,
113 dataKey,
114 autoSelectMatches,
115 focusFirstItem = false,
116 value,
117 defaultValue = '',
118 onChange,
119 open,
120 defaultOpen = false,
121 onToggle,
122 filter = true,
123 busy,
124 disabled,
125 readOnly,
126 selectIcon = caretDown,
127 hideCaret,
128 hideEmptyPopup,
129 busySpinner,
130 dropUp,
131 tabIndex,
132 popupTransition,
133 name,
134 onSelect,
135 onKeyDown,
136 onBlur,
137 onFocus,
138 inputProps,
139 listProps,
140 groupBy,
141 renderListItem,
142 renderListGroup,
143 optionComponent,
144 listComponent: ListComponent = List,
145 popupComponent: Popup = BasePopup,
146 data: rawData = [],
147 messages: userMessages
148 } = _ref,
149 elementProps = _objectWithoutPropertiesLoose(_ref, _excluded);
150
151 let [currentValue, handleChange] = useUncontrolledProp(value, defaultValue, onChange);
152 const [currentOpen, handleOpen] = useUncontrolledProp(open, defaultOpen, onToggle);
153 const ref = useRef(null);
154 const inputRef = useRef(null);
155 const listRef = useRef(null);
156 const [suggestion, setSuggestion] = useState(null);
157 const shouldFilter = useRef(false);
158 const inputId = useInstanceId(id, '_input');
159 const listId = useInstanceId(id, '_listbox');
160 const activeId = useInstanceId(id, '_listbox_active_option');
161 const accessors = useAccessors(textField, dataKey);
162 const messages = useMessagesWithDefaults(userMessages);
163 const toggle = useDropdownToggle(currentOpen, handleOpen);
164 const isDisabled = disabled === true;
165 const isReadOnly = !!readOnly;
166 const data = useFilteredData(rawData, filter, shouldFilter.current ? accessors.text(currentValue) : void 0, accessors.text);
167 const selectedItem = useMemo(() => data[accessors.indexOf(data, currentValue)], [data, currentValue, accessors]);
168 const list = useFocusList({
169 activeId,
170 scope: ref,
171 focusFirstItem,
172 anchorItem: currentOpen ? selectedItem : undefined
173 });
174 const [focusEvents, focused] = useFocusManager(ref, {
175 disabled: isDisabled,
176 onBlur,
177 onFocus
178 }, {
179 didHandle(focused) {
180 if (!focused) {
181 shouldFilter.current = false;
182 toggle.close();
183 setSuggestion(null);
184 list.focus(undefined);
185 } else {
186 focus({
187 preventScroll: true
188 });
189 }
190 }
191
192 });
193 useActiveDescendant(ref, activeId, currentOpen, [list.getFocused()]);
194 /**
195 * Handlers
196 */
197
198 const handleClick = e => {
199 if (readOnly || isDisabled) return; // prevents double clicks when in a <label>
200
201 e.preventDefault();
202 focus();
203 toggle();
204 };
205
206 const handleSelect = (data, originalEvent) => {
207 toggle.close();
208 shouldFilter.current = false;
209 setSuggestion(null);
210 notify(onSelect, [data, {
211 originalEvent
212 }]);
213 change(data, originalEvent, true);
214 focus({
215 preventScroll: true
216 });
217 };
218
219 const handleInputKeyDown = ({
220 key
221 }) => {
222 if (key === 'Backspace' || key === 'Delete') {
223 list.focus(null);
224 }
225 };
226
227 const handleInputChange = event => {
228 let idx = autoSelectMatches ? indexOf(rawData, event.target.value.toLowerCase(), accessors.text) : -1;
229 shouldFilter.current = true;
230 setSuggestion(null);
231 const nextValue = idx === -1 ? event.target.value : rawData[idx];
232 change(nextValue, event);
233 if (!nextValue) toggle.close();else toggle.open();
234 };
235
236 const handleKeyDown = e => {
237 if (readOnly) return;
238 let {
239 key,
240 altKey,
241 shiftKey
242 } = e;
243 notify(onKeyDown, [e]);
244 if (e.defaultPrevented) return;
245
246 const select = item => item != null && handleSelect(item, e);
247
248 const setFocused = el => {
249 if (!el) return;
250 setSuggestion(list.toDataItem(el));
251 list.focus(el);
252 };
253
254 if (key === 'End' && currentOpen && !shiftKey) {
255 e.preventDefault();
256 setFocused(list.last());
257 } else if (key === 'Home' && currentOpen && !shiftKey) {
258 e.preventDefault();
259 setFocused(list.first());
260 } else if (key === 'Escape' && currentOpen) {
261 e.preventDefault();
262 setSuggestion(null);
263 toggle.close();
264 } else if (key === 'Enter' && currentOpen) {
265 e.preventDefault();
266 select(list.getFocused());
267 } else if (key === 'ArrowDown') {
268 e.preventDefault();
269
270 if (currentOpen) {
271 setFocused(list.next());
272 } else {
273 return toggle.open();
274 }
275 } else if (key === 'ArrowUp') {
276 e.preventDefault();
277 if (altKey) return toggle.close();
278
279 if (currentOpen) {
280 setFocused(list.prev());
281 }
282 }
283 };
284 /**
285 * Methods
286 */
287
288
289 function focus(opts) {
290 if (inputRef.current) inputRef.current.focus(opts);
291 }
292
293 function change(nextValue, originalEvent, selected = false) {
294 handleChange(nextValue, {
295 lastValue: currentValue,
296 originalEvent,
297 source: selected ? 'listbox' : 'input'
298 });
299 }
300 /**
301 * Rendering
302 */
303
304
305 useImperativeHandle(outerRef, () => ({
306 focus
307 }));
308 let shouldRenderPopup = useFirstFocusedRender(focused, currentOpen);
309 let valueItem = accessors.findOrSelf(data, currentValue);
310 let inputValue = accessors.text(suggestion || valueItem);
311 let completeType = filter ? 'list' : 'none';
312 let popupOpen = currentOpen && (!hideEmptyPopup || !!data.length);
313 let inputReadOnly = // @ts-ignore
314 (inputProps == null ? void 0 : inputProps.readOnly) != null ? inputProps == null ? void 0 : inputProps.readOnly : readOnly;
315 let inputAddon = false;
316
317 if (!hideCaret) {
318 inputAddon = /*#__PURE__*/React.createElement(InputAddon, {
319 busy: busy,
320 icon: selectIcon,
321 spinner: busySpinner,
322 onClick: handleClick,
323 disabled: !!isDisabled || isReadOnly // FIXME
324 ,
325 label: messages.openCombobox()
326 });
327 } else if (busy) {
328 inputAddon = /*#__PURE__*/React.createElement("span", {
329 "aria-hidden": "true",
330 className: "rw-btn rw-picker-caret"
331 }, busySpinner || Spinner);
332 }
333
334 return /*#__PURE__*/React.createElement(Widget, _extends({}, elementProps, {
335 ref: ref,
336 open: currentOpen,
337 dropUp: dropUp,
338 focused: focused,
339 disabled: isDisabled,
340 readOnly: isReadOnly
341 }, focusEvents, {
342 onKeyDown: handleKeyDown,
343 className: cn(className, 'rw-combobox')
344 }), /*#__PURE__*/React.createElement(WidgetPicker, {
345 className: cn(containerClassName, hideCaret && 'rw-widget-input', hideCaret && !busy && 'rw-hide-caret')
346 }, /*#__PURE__*/React.createElement(Input, _extends({}, inputProps, {
347 role: "combobox",
348 name: name,
349 id: inputId,
350 className: cn( // @ts-ignore
351 inputProps && inputProps.className, 'rw-combobox-input', !hideCaret && 'rw-widget-input'),
352 autoFocus: autoFocus,
353 tabIndex: tabIndex,
354 disabled: isDisabled,
355 readOnly: inputReadOnly,
356 "aria-busy": !!busy,
357 "aria-owns": listId,
358 "aria-autocomplete": completeType,
359 "aria-expanded": currentOpen,
360 "aria-haspopup": true,
361 placeholder: placeholder,
362 value: inputValue,
363 onChange: handleInputChange,
364 onKeyDown: handleInputKeyDown,
365 ref: inputRef
366 })), inputAddon), /*#__PURE__*/React.createElement(FocusListContext.Provider, {
367 value: list.context
368 }, shouldRenderPopup && /*#__PURE__*/React.createElement(Popup, {
369 dropUp: dropUp,
370 open: popupOpen,
371 transition: popupTransition,
372 onEntering: () => listRef.current.scrollIntoView()
373 }, /*#__PURE__*/React.createElement(ListComponent, _extends({}, listProps, {
374 id: listId,
375 tabIndex: -1,
376 data: data,
377 groupBy: groupBy,
378 disabled: disabled,
379 accessors: accessors,
380 renderItem: renderListItem,
381 renderGroup: renderListGroup,
382 optionComponent: optionComponent,
383 value: selectedItem,
384 searchTerm: valueItem && accessors.text(valueItem) || '',
385 "aria-hidden": !popupOpen,
386 "aria-labelledby": inputId,
387 "aria-live": popupOpen ? 'polite' : void 0,
388 onChange: (d, meta) => handleSelect(d, meta.originalEvent),
389 ref: listRef,
390 messages: {
391 emptyList: rawData.length ? messages.emptyFilter : messages.emptyList
392 }
393 })))));
394});
395ComboboxImpl.displayName = 'Combobox';
396ComboboxImpl.propTypes = propTypes;
397export default ComboboxImpl;
\No newline at end of file