react-widgets
Version:
An à la carte set of polished, extensible, and accessible inputs built for React
397 lines (362 loc) • 12.8 kB
JavaScript
const _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"];
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); }
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; }
import cn from 'classnames';
import * as PropTypes from 'prop-types';
import * as React from 'react';
import { useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useUncontrolledProp } from 'uncontrollable';
import { caretDown } from './Icon';
import Input from './Input';
import List from './List';
import { FocusListContext, useFocusList } from './FocusListContext';
import BasePopup from './Popup';
import InputAddon from './InputAddon';
import Widget from './Widget';
import WidgetPicker from './WidgetPicker';
import { useMessagesWithDefaults } from './messages';
import { useActiveDescendant } from './A11y';
import * as CustomPropTypes from './PropTypes';
import { useAccessors } from './Accessors';
import { useFilteredData } from './Filter';
import useDropdownToggle from './useDropdownToggle';
import useFocusManager from './useFocusManager';
import { notify, useFirstFocusedRender, useInstanceId } from './WidgetHelpers';
import { Spinner } from './Icon';
function indexOf(data, searchTerm, text) {
if (!searchTerm.trim()) return -1;
for (let idx = 0; idx < data.length; idx++) if (text(data[idx]).toLowerCase() === searchTerm) return idx;
return -1;
}
let propTypes = {
value: PropTypes.any,
onChange: PropTypes.func,
open: PropTypes.bool,
onToggle: PropTypes.func,
renderListItem: PropTypes.func,
listComponent: PropTypes.elementType,
renderListGroup: PropTypes.func,
groupBy: CustomPropTypes.accessor,
data: PropTypes.array,
dataKey: CustomPropTypes.accessor,
textField: CustomPropTypes.accessor,
name: PropTypes.string,
/** Do not show the auto complete list when it returns no results. */
hideEmptyPopup: PropTypes.bool,
/** Hide the combobox dropdown indicator. */
hideCaret: PropTypes.bool,
/**
*
* @type {(dataItem: ?any, metadata: { originalEvent: SyntheticEvent }) => void}
*/
onSelect: PropTypes.func,
autoFocus: PropTypes.bool,
disabled: CustomPropTypes.disabled.acceptsArray,
readOnly: CustomPropTypes.disabled,
busy: PropTypes.bool,
/** Specify the element used to render the select (down arrow) icon. */
selectIcon: PropTypes.node,
/** Specify the element used to render the busy indicator */
busySpinner: PropTypes.node,
dropUp: PropTypes.bool,
popupTransition: PropTypes.elementType,
placeholder: PropTypes.string,
/** Adds a css class to the input container element. */
containerClassName: PropTypes.string,
inputProps: PropTypes.object,
listProps: PropTypes.object,
messages: PropTypes.shape({
openCombobox: CustomPropTypes.message,
emptyList: CustomPropTypes.message,
emptyFilter: CustomPropTypes.message
})
};
/**
* ---
* shortcuts:
* - { key: alt + down arrow, label: open combobox }
* - { key: alt + up arrow, label: close combobox }
* - { key: down arrow, label: move focus to next item }
* - { key: up arrow, label: move focus to previous item }
* - { key: home, label: move focus to first item }
* - { key: end, label: move focus to last item }
* - { key: enter, label: select focused item }
* - { key: any key, label: search list for item starting with key }
* ---
*
* Select an item from the list, or input a custom value. The Combobox can also make suggestions as you type.
* @public
*/
const ComboboxImpl = /*#__PURE__*/React.forwardRef(function Combobox(_ref, outerRef) {
let {
id,
className,
containerClassName,
placeholder,
autoFocus,
textField,
dataKey,
autoSelectMatches,
focusFirstItem = false,
value,
defaultValue = '',
onChange,
open,
defaultOpen = false,
onToggle,
filter = true,
busy,
disabled,
readOnly,
selectIcon = caretDown,
hideCaret,
hideEmptyPopup,
busySpinner,
dropUp,
tabIndex,
popupTransition,
name,
onSelect,
onKeyDown,
onBlur,
onFocus,
inputProps,
listProps,
groupBy,
renderListItem,
renderListGroup,
optionComponent,
listComponent: ListComponent = List,
popupComponent: Popup = BasePopup,
data: rawData = [],
messages: userMessages
} = _ref,
elementProps = _objectWithoutPropertiesLoose(_ref, _excluded);
let [currentValue, handleChange] = useUncontrolledProp(value, defaultValue, onChange);
const [currentOpen, handleOpen] = useUncontrolledProp(open, defaultOpen, onToggle);
const ref = useRef(null);
const inputRef = useRef(null);
const listRef = useRef(null);
const [suggestion, setSuggestion] = useState(null);
const shouldFilter = useRef(false);
const inputId = useInstanceId(id, '_input');
const listId = useInstanceId(id, '_listbox');
const activeId = useInstanceId(id, '_listbox_active_option');
const accessors = useAccessors(textField, dataKey);
const messages = useMessagesWithDefaults(userMessages);
const toggle = useDropdownToggle(currentOpen, handleOpen);
const isDisabled = disabled === true;
const isReadOnly = !!readOnly;
const data = useFilteredData(rawData, filter, shouldFilter.current ? accessors.text(currentValue) : void 0, accessors.text);
const selectedItem = useMemo(() => data[accessors.indexOf(data, currentValue)], [data, currentValue, accessors]);
const list = useFocusList({
activeId,
scope: ref,
focusFirstItem,
anchorItem: currentOpen ? selectedItem : undefined
});
const [focusEvents, focused] = useFocusManager(ref, {
disabled: isDisabled,
onBlur,
onFocus
}, {
didHandle(focused) {
if (!focused) {
shouldFilter.current = false;
toggle.close();
setSuggestion(null);
list.focus(undefined);
} else {
focus({
preventScroll: true
});
}
}
});
useActiveDescendant(ref, activeId, currentOpen, [list.getFocused()]);
/**
* Handlers
*/
const handleClick = e => {
if (readOnly || isDisabled) return; // prevents double clicks when in a <label>
e.preventDefault();
focus();
toggle();
};
const handleSelect = (data, originalEvent) => {
toggle.close();
shouldFilter.current = false;
setSuggestion(null);
notify(onSelect, [data, {
originalEvent
}]);
change(data, originalEvent, true);
focus({
preventScroll: true
});
};
const handleInputKeyDown = ({
key
}) => {
if (key === 'Backspace' || key === 'Delete') {
list.focus(null);
}
};
const handleInputChange = event => {
let idx = autoSelectMatches ? indexOf(rawData, event.target.value.toLowerCase(), accessors.text) : -1;
shouldFilter.current = true;
setSuggestion(null);
const nextValue = idx === -1 ? event.target.value : rawData[idx];
change(nextValue, event);
if (!nextValue) toggle.close();else toggle.open();
};
const handleKeyDown = e => {
if (readOnly) return;
let {
key,
altKey,
shiftKey
} = e;
notify(onKeyDown, [e]);
if (e.defaultPrevented) return;
const select = item => item != null && handleSelect(item, e);
const setFocused = el => {
if (!el) return;
setSuggestion(list.toDataItem(el));
list.focus(el);
};
if (key === 'End' && currentOpen && !shiftKey) {
e.preventDefault();
setFocused(list.last());
} else if (key === 'Home' && currentOpen && !shiftKey) {
e.preventDefault();
setFocused(list.first());
} else if (key === 'Escape' && currentOpen) {
e.preventDefault();
setSuggestion(null);
toggle.close();
} else if (key === 'Enter' && currentOpen) {
e.preventDefault();
select(list.getFocused());
} else if (key === 'ArrowDown') {
e.preventDefault();
if (currentOpen) {
setFocused(list.next());
} else {
return toggle.open();
}
} else if (key === 'ArrowUp') {
e.preventDefault();
if (altKey) return toggle.close();
if (currentOpen) {
setFocused(list.prev());
}
}
};
/**
* Methods
*/
function focus(opts) {
if (inputRef.current) inputRef.current.focus(opts);
}
function change(nextValue, originalEvent, selected = false) {
handleChange(nextValue, {
lastValue: currentValue,
originalEvent,
source: selected ? 'listbox' : 'input'
});
}
/**
* Rendering
*/
useImperativeHandle(outerRef, () => ({
focus
}));
let shouldRenderPopup = useFirstFocusedRender(focused, currentOpen);
let valueItem = accessors.findOrSelf(data, currentValue);
let inputValue = accessors.text(suggestion || valueItem);
let completeType = filter ? 'list' : 'none';
let popupOpen = currentOpen && (!hideEmptyPopup || !!data.length);
let inputReadOnly = // @ts-ignore
(inputProps == null ? void 0 : inputProps.readOnly) != null ? inputProps == null ? void 0 : inputProps.readOnly : readOnly;
let inputAddon = false;
if (!hideCaret) {
inputAddon = /*#__PURE__*/React.createElement(InputAddon, {
busy: busy,
icon: selectIcon,
spinner: busySpinner,
onClick: handleClick,
disabled: !!isDisabled || isReadOnly // FIXME
,
label: messages.openCombobox()
});
} else if (busy) {
inputAddon = /*#__PURE__*/React.createElement("span", {
"aria-hidden": "true",
className: "rw-btn rw-picker-caret"
}, busySpinner || Spinner);
}
return /*#__PURE__*/React.createElement(Widget, _extends({}, elementProps, {
ref: ref,
open: currentOpen,
dropUp: dropUp,
focused: focused,
disabled: isDisabled,
readOnly: isReadOnly
}, focusEvents, {
onKeyDown: handleKeyDown,
className: cn(className, 'rw-combobox')
}), /*#__PURE__*/React.createElement(WidgetPicker, {
className: cn(containerClassName, hideCaret && 'rw-widget-input', hideCaret && !busy && 'rw-hide-caret')
}, /*#__PURE__*/React.createElement(Input, _extends({}, inputProps, {
role: "combobox",
name: name,
id: inputId,
className: cn( // @ts-ignore
inputProps && inputProps.className, 'rw-combobox-input', !hideCaret && 'rw-widget-input'),
autoFocus: autoFocus,
tabIndex: tabIndex,
disabled: isDisabled,
readOnly: inputReadOnly,
"aria-busy": !!busy,
"aria-owns": listId,
"aria-autocomplete": completeType,
"aria-expanded": currentOpen,
"aria-haspopup": true,
placeholder: placeholder,
value: inputValue,
onChange: handleInputChange,
onKeyDown: handleInputKeyDown,
ref: inputRef
})), inputAddon), /*#__PURE__*/React.createElement(FocusListContext.Provider, {
value: list.context
}, shouldRenderPopup && /*#__PURE__*/React.createElement(Popup, {
dropUp: dropUp,
open: popupOpen,
transition: popupTransition,
onEntering: () => listRef.current.scrollIntoView()
}, /*#__PURE__*/React.createElement(ListComponent, _extends({}, listProps, {
id: listId,
tabIndex: -1,
data: data,
groupBy: groupBy,
disabled: disabled,
accessors: accessors,
renderItem: renderListItem,
renderGroup: renderListGroup,
optionComponent: optionComponent,
value: selectedItem,
searchTerm: valueItem && accessors.text(valueItem) || '',
"aria-hidden": !popupOpen,
"aria-labelledby": inputId,
"aria-live": popupOpen ? 'polite' : void 0,
onChange: (d, meta) => handleSelect(d, meta.originalEvent),
ref: listRef,
messages: {
emptyList: rawData.length ? messages.emptyFilter : messages.emptyList
}
})))));
});
ComboboxImpl.displayName = 'Combobox';
ComboboxImpl.propTypes = propTypes;
export default ComboboxImpl;