react-widgets
Version:
An à la carte set of polished, extensible, and accessible inputs built for React
595 lines (511 loc) • 20.4 kB
JavaScript
"use strict";
exports.__esModule = true;
exports.default = void 0;
var _classnames = _interopRequireDefault(require("classnames"));
var _closest = _interopRequireDefault(require("dom-helpers/closest"));
var _propTypes = _interopRequireDefault(require("prop-types"));
var _react = _interopRequireWildcard(require("react"));
var _uncontrollable = require("uncontrollable");
var _AddToListOption = _interopRequireWildcard(require("./AddToListOption"));
var _Icon = require("./Icon");
var _List = _interopRequireDefault(require("./List"));
var _FocusListContext = require("./FocusListContext");
var _MultiselectInput = _interopRequireDefault(require("./MultiselectInput"));
var _MultiselectTagList = _interopRequireDefault(require("./MultiselectTagList"));
var _Popup = _interopRequireDefault(require("./Popup"));
var _Widget = _interopRequireDefault(require("./Widget"));
var _WidgetPicker = _interopRequireDefault(require("./WidgetPicker"));
var _messages = require("./messages");
var _A11y = require("./A11y");
var _Filter = require("./Filter");
var CustomPropTypes = _interopRequireWildcard(require("./PropTypes"));
var _canShowCreate = _interopRequireDefault(require("./canShowCreate"));
var _Accessors = require("./Accessors");
var _useDropdownToggle = _interopRequireDefault(require("./useDropdownToggle"));
var _useFocusManager = _interopRequireDefault(require("./useFocusManager"));
var _WidgetHelpers = require("./WidgetHelpers");
var _PickerCaret = _interopRequireDefault(require("./PickerCaret"));
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"];
function _getRequireWildcardCache(nodeInterop) { if (typeof WeakMap !== "function") return null; var cacheBabelInterop = new WeakMap(); var cacheNodeInterop = new WeakMap(); return (_getRequireWildcardCache = function (nodeInterop) { return nodeInterop ? cacheNodeInterop : cacheBabelInterop; })(nodeInterop); }
function _interopRequireWildcard(obj, nodeInterop) { if (!nodeInterop && obj && obj.__esModule) { return obj; } if (obj === null || typeof obj !== "object" && typeof obj !== "function") { return { default: obj }; } var cache = _getRequireWildcardCache(nodeInterop); if (cache && cache.has(obj)) { return cache.get(obj); } var newObj = {}; var hasPropertyDescriptor = Object.defineProperty && Object.getOwnPropertyDescriptor; for (var key in obj) { if (key !== "default" && Object.prototype.hasOwnProperty.call(obj, key)) { var desc = hasPropertyDescriptor ? Object.getOwnPropertyDescriptor(obj, key) : null; if (desc && (desc.get || desc.set)) { Object.defineProperty(newObj, key, desc); } else { newObj[key] = obj[key]; } } } newObj.default = obj; if (cache) { cache.set(obj, newObj); } return newObj; }
function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }
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; }
const ENTER = 13;
const INSERT = 'insert';
const REMOVE = 'remove';
let propTypes = {
data: _propTypes.default.array,
//-- controlled props --
value: _propTypes.default.array,
/**
* @type {function (
* dataItems: ?any[],
* metadata: {
* dataItem: any,
* action: 'insert' | 'remove',
* originalEvent: SyntheticEvent,
* lastValue: ?any[],
* searchTerm: ?string
* }
* ): void}
*/
onChange: _propTypes.default.func,
searchTerm: _propTypes.default.string,
/**
* @type {function (
* searchTerm: ?string,
* metadata: {
* action: 'clear' | 'input',
* lastSearchTerm: ?string,
* originalEvent: SyntheticEvent,
* }
* ): void}
*/
onSearch: _propTypes.default.func,
open: _propTypes.default.bool,
handleOpen: _propTypes.default.func,
//-------------------------------------------
dataKey: CustomPropTypes.accessor,
textField: CustomPropTypes.accessor,
renderTagValue: _propTypes.default.func,
renderListItem: _propTypes.default.func,
renderListGroup: _propTypes.default.func,
groupBy: CustomPropTypes.accessor,
allowCreate: _propTypes.default.oneOf([true, false, 'onFilter']),
/**
*
* @type { (dataItem: ?any, metadata: { originalEvent: SyntheticEvent }) => void }
*/
onSelect: _propTypes.default.func,
/**
* @type { (searchTerm: string) => void }
*/
onCreate: _propTypes.default.func,
busy: _propTypes.default.bool,
/** Specify the element used to render the select (down arrow) icon. */
selectIcon: _propTypes.default.node,
/** Specify the element used to render tag clear icons. */
clearTagIcon: _propTypes.default.node,
/** Specify the element used to render the busy indicator */
busySpinner: _propTypes.default.node,
dropUp: _propTypes.default.bool,
popupTransition: _propTypes.default.elementType,
/** Adds a css class to the input container element. */
containerClassName: _propTypes.default.string,
inputProps: _propTypes.default.object,
listProps: _propTypes.default.object,
autoFocus: _propTypes.default.bool,
placeholder: _propTypes.default.string,
/** Continue to show the input placeholder even if tags are selected */
showPlaceholderWithValues: _propTypes.default.bool,
/** Continue to show the selected items in the dropdown list */
showSelectedItemsInList: _propTypes.default.bool,
disabled: CustomPropTypes.disabled.acceptsArray,
readOnly: CustomPropTypes.disabled,
messages: _propTypes.default.shape({
open: CustomPropTypes.message,
emptyList: CustomPropTypes.message,
emptyFilter: CustomPropTypes.message,
createOption: CustomPropTypes.message,
tagsLabel: CustomPropTypes.message,
selectedItems: CustomPropTypes.message,
noneSelected: CustomPropTypes.message,
removeLabel: CustomPropTypes.message
})
};
const EMPTY_ARRAY = [];
function useMultiselectData(value = EMPTY_ARRAY, data, accessors, filter, searchTerm, showSelectedItemsInList) {
data = (0, _react.useMemo)(() => showSelectedItemsInList ? data : data.filter(i => !value.some(v => accessors.matches(i, v))), [data, showSelectedItemsInList, value, accessors]);
return [(0, _Filter.useFilteredData)(data, filter || false, searchTerm, accessors.text), data.length];
}
/**
* ---
* shortcuts:
* - { key: left arrow, label: move focus to previous tag }
* - { key: right arrow, label: move focus to next tag }
* - { key: delete, deselect focused tag }
* - { key: backspace, deselect next tag }
* - { key: alt + up arrow, label: close Multiselect }
* - { key: down arrow, label: open Multiselect, and 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: ctrl + enter, label: create new tag from current searchTerm }
* - { key: any key, label: search list for item starting with key }
* ---
*
* A select listbox alternative.
*
* @public
*/
const Multiselect = /*#__PURE__*/_react.default.forwardRef(function Multiselect(_ref, outerRef) {
let {
dataKey,
textField,
autoFocus,
id,
value,
defaultValue = [],
onChange,
open,
defaultOpen = false,
onToggle,
focusFirstItem = false,
searchTerm,
defaultSearchTerm = '',
onSearch,
filter = 'startsWith',
allowCreate = false,
className,
containerClassName,
placeholder,
busy,
disabled,
readOnly,
selectIcon,
clearTagIcon = _Icon.times,
busySpinner,
dropUp,
tabIndex,
popupTransition,
showPlaceholderWithValues = false,
showSelectedItemsInList = false,
onSelect,
onCreate,
onKeyDown,
onBlur,
onFocus,
inputProps,
listProps,
renderListItem,
renderListGroup,
renderTagValue,
optionComponent,
tagOptionComponent,
groupBy,
listComponent: ListComponent = _List.default,
popupComponent: Popup = _Popup.default,
data: rawData = [],
messages: userMessages
} = _ref,
elementProps = _objectWithoutPropertiesLoose(_ref, _excluded);
let [currentValue, handleChange] = (0, _uncontrollable.useUncontrolledProp)(value, defaultValue, onChange);
const [currentOpen, handleOpen] = (0, _uncontrollable.useUncontrolledProp)(open, defaultOpen, onToggle);
const [currentSearch, handleSearch] = (0, _uncontrollable.useUncontrolledProp)(searchTerm, defaultSearchTerm, onSearch);
const ref = (0, _react.useRef)(null);
const inputRef = (0, _react.useRef)(null);
const listRef = (0, _react.useRef)(null);
const inputId = (0, _WidgetHelpers.useInstanceId)(id, '_input');
const tagsId = (0, _WidgetHelpers.useInstanceId)(id, '_taglist');
const listId = (0, _WidgetHelpers.useInstanceId)(id, '_listbox');
const createId = (0, _WidgetHelpers.useInstanceId)(id, '_createlist_option');
const activeTagId = (0, _WidgetHelpers.useInstanceId)(id, '_taglist_active_tag');
const activeOptionId = (0, _WidgetHelpers.useInstanceId)(id, '_listbox_active_option');
const accessors = (0, _Accessors.useAccessors)(textField, dataKey);
const messages = (0, _messages.useMessagesWithDefaults)(userMessages);
const toggle = (0, _useDropdownToggle.default)(currentOpen, handleOpen);
const isDisabled = disabled === true;
const isReadOnly = !!readOnly;
const [focusEvents, focused] = (0, _useFocusManager.default)(ref, {
disabled: isDisabled,
onBlur,
onFocus
}, {
didHandle(focused, event) {
if (focused) return focus();
toggle.close();
clearSearch(event);
tagList.focus(null);
}
});
const dataItems = (0, _react.useMemo)(() => currentValue.map(item => accessors.findOrSelf(rawData, item)), [rawData, currentValue, accessors]);
const [data, lengthWithoutValues] = useMultiselectData(dataItems, rawData, accessors, currentOpen ? filter : false, currentSearch, showSelectedItemsInList);
const list = (0, _FocusListContext.useFocusList)({
scope: ref,
scopeSelector: '.rw-popup',
focusFirstItem,
activeId: activeOptionId,
anchorItem: currentOpen ? dataItems[dataItems.length - 1] : undefined
});
const tagList = (0, _FocusListContext.useFocusList)({
scope: ref,
scopeSelector: '.rw-multiselect-taglist',
activeId: activeTagId
});
const showCreateOption = (0, _canShowCreate.default)(allowCreate, {
searchTerm: currentSearch,
data,
dataItems,
accessors
});
/**
* Update aria when it changes on update
*/
const focusedTag = tagList.getFocused();
(0, _react.useEffect)(() => {
if (currentOpen) return;
(0, _A11y.setActiveDescendant)(inputRef.current, focusedTag ? activeTagId : '');
}, [activeTagId, currentOpen, focusedTag]);
const focusedItem = list.getFocused();
(0, _react.useEffect)(() => {
if (!currentOpen) return; // if (focusedItem) tagList.focus(null)
(0, _A11y.setActiveDescendant)(inputRef.current, focusedItem ? activeOptionId : '');
}, [activeOptionId, currentOpen, focusedItem]);
/**
* Event Handlers
*/
const handleDelete = (dataItem, event) => {
if (isDisabled || readOnly || tagList.size() === 0) return;
focus();
change(dataItem, event, REMOVE);
};
const deletingRef = (0, _react.useRef)(false);
const handleSearchKeyDown = e => {
if (e.key === 'Backspace' && e.currentTarget.value && !deletingRef.current) deletingRef.current = true;
};
const handleSearchKeyUp = e => {
if (e.key === 'Backspace' && deletingRef.current) {
deletingRef.current = false;
}
};
const handleInputChange = e => {
search(e.target.value, e, 'input');
toggle.open();
};
const handleClick = e => {
if (isDisabled || readOnly) return; // prevents double clicks when in a <label>
e.preventDefault();
focus();
if ((0, _closest.default)(e.target, '.rw-select') && currentOpen) {
toggle.close();
} else toggle.open();
};
const handleDoubleClick = () => {
if (isDisabled || !inputRef.current) return;
focus();
if (inputRef.current) inputRef.current.select();
};
const handleSelect = (dataItem, originalEvent) => {
if (dataItem === undefined) return;
originalEvent.preventDefault();
if (dataItem === _AddToListOption.CREATE_OPTION) {
handleCreate(originalEvent);
return;
}
(0, _WidgetHelpers.notify)(onSelect, [dataItem, {
originalEvent
}]);
if (!showSelectedItemsInList || !dataItems.includes(dataItem)) {
change(dataItem, originalEvent, INSERT);
} else {
change(dataItem, originalEvent, REMOVE);
}
focus();
};
const handleCreate = event => {
(0, _WidgetHelpers.notify)(onCreate, [currentSearch]);
clearSearch(event);
focus();
};
const handleKeyDown = event => {
if (readOnly) {
event.preventDefault();
return;
}
let {
key,
keyCode,
altKey,
ctrlKey
} = event;
(0, _WidgetHelpers.notify)(onKeyDown, [event]);
if (event.defaultPrevented) return;
if (key === 'ArrowDown') {
event.preventDefault();
if (!currentOpen) {
toggle.open();
return;
}
list.focus(list.next());
tagList.focus(null);
} else if (key === 'ArrowUp' && (currentOpen || altKey)) {
event.preventDefault();
if (altKey) {
toggle.close();
return;
}
list.focus(list.prev());
tagList.focus(null);
} else if (key === 'End') {
event.preventDefault();
if (currentOpen) {
list.focus(list.last());
tagList.focus(null);
} else {
tagList.focus(tagList.last());
list.focus(null);
}
} else if (key === 'Home') {
event.preventDefault();
if (currentOpen) list.focus(list.first());else list.focus(tagList.first());
} else if (currentOpen && keyCode === ENTER) {
// using keyCode to ignore enter for japanese IME
event.preventDefault();
if (ctrlKey && showCreateOption) {
return handleCreate(event);
}
handleSelect(list.getFocused(), event);
} else if (key === 'Escape') {
if (currentOpen) toggle.close();else tagList.focus(null); //
} else if (!currentSearch && !deletingRef.current) {
//
if (key === 'ArrowLeft') {
tagList.focus(tagList.prev({
behavior: 'loop'
}));
} else if (key === 'ArrowRight') {
tagList.focus(tagList.next({
behavior: 'loop'
})); //
} else if (key === 'Delete' && tagList.getFocused()) {
handleDelete(tagList.getFocused(), event); //
} else if (key === 'Backspace') {
handleDelete(tagList.toDataItem(tagList.last()), event);
} else if (key === ' ' && !currentOpen) {
event.preventDefault();
toggle.open();
}
}
};
/**
* Methods
*/
function change(dataItem, originalEvent, action) {
let nextDataItems = dataItems;
switch (action) {
case INSERT:
nextDataItems = nextDataItems.concat(dataItem);
break;
case REMOVE:
nextDataItems = nextDataItems.filter(d => d !== dataItem);
break;
}
handleChange(nextDataItems, {
action,
dataItem,
originalEvent,
searchTerm: currentSearch,
lastValue: currentValue
});
clearSearch(originalEvent);
}
function clearSearch(originalEvent) {
search('', originalEvent, 'clear');
}
function search(nextSearchTerm, originalEvent, action = 'input') {
if (nextSearchTerm !== currentSearch) handleSearch(nextSearchTerm, {
action,
originalEvent,
lastSearchTerm: currentSearch
});
}
function focus() {
if (inputRef.current) inputRef.current.focus();
}
/**
* Render
*/
(0, _react.useImperativeHandle)(outerRef, () => ({
focus
}));
let shouldRenderPopup = (0, _WidgetHelpers.useFirstFocusedRender)(focused, currentOpen);
let shouldRenderTags = !!dataItems.length;
let inputOwns = `${listId} ` + (shouldRenderTags ? tagsId : '') + (showCreateOption ? createId : '');
return /*#__PURE__*/_react.default.createElement(_Widget.default, _extends({}, elementProps, {
ref: ref,
open: currentOpen,
dropUp: dropUp,
focused: focused,
disabled: isDisabled,
readOnly: isReadOnly,
onKeyDown: handleKeyDown
}, focusEvents, {
className: (0, _classnames.default)(className, 'rw-multiselect')
}), /*#__PURE__*/_react.default.createElement(_WidgetPicker.default, {
onClick: handleClick,
onTouchEnd: handleClick,
onDoubleClick: handleDoubleClick,
className: (0, _classnames.default)(containerClassName, 'rw-widget-input')
}, /*#__PURE__*/_react.default.createElement(_FocusListContext.FocusListContext.Provider, {
value: tagList.context
}, /*#__PURE__*/_react.default.createElement(_MultiselectTagList.default, {
id: tagsId,
textAccessor: accessors.text,
clearTagIcon: clearTagIcon,
label: messages.tagsLabel(),
value: dataItems,
readOnly: isReadOnly,
disabled: disabled,
onDelete: handleDelete,
tagOptionComponent: tagOptionComponent,
renderTagValue: renderTagValue
}, /*#__PURE__*/_react.default.createElement(_MultiselectInput.default, _extends({}, inputProps, {
role: "combobox",
autoFocus: autoFocus,
tabIndex: tabIndex || 0,
"aria-expanded": !!currentOpen,
"aria-busy": !!busy,
"aria-owns": inputOwns,
"aria-controls": listId,
"aria-haspopup": "listbox",
"aria-autocomplete": "list",
value: currentSearch,
disabled: isDisabled,
readOnly: isReadOnly,
placeholder: (currentValue.length && !showPlaceholderWithValues ? '' : placeholder) || '',
onKeyDown: handleSearchKeyDown,
onKeyUp: handleSearchKeyUp,
onChange: handleInputChange,
ref: inputRef
})))), /*#__PURE__*/_react.default.createElement(_PickerCaret.default, {
busy: busy,
spinner: busySpinner,
icon: selectIcon,
visible: focused
})), /*#__PURE__*/_react.default.createElement(_FocusListContext.FocusListContext.Provider, {
value: list.context
}, shouldRenderPopup && /*#__PURE__*/_react.default.createElement(Popup, {
dropUp: dropUp,
open: currentOpen,
transition: popupTransition,
onEntering: () => listRef.current.scrollIntoView()
}, /*#__PURE__*/_react.default.createElement(ListComponent, _extends({}, listProps, {
id: listId,
data: data,
tabIndex: -1,
disabled: disabled,
searchTerm: currentSearch,
accessors: accessors,
renderItem: renderListItem,
renderGroup: renderListGroup,
value: dataItems,
groupBy: groupBy,
optionComponent: optionComponent,
onChange: (d, meta) => handleSelect(d, meta.originalEvent),
"aria-live": "polite",
"aria-labelledby": inputId,
"aria-hidden": !currentOpen,
ref: listRef,
messages: {
emptyList: lengthWithoutValues ? messages.emptyFilter : messages.emptyList
}
})), showCreateOption && /*#__PURE__*/_react.default.createElement(_AddToListOption.default, {
onSelect: handleCreate
}, messages.createOption(currentValue, currentSearch)))));
});
Multiselect.displayName = 'Multiselect';
Multiselect.propTypes = propTypes;
var _default = Multiselect;
exports.default = _default;