react-widgets
Version:
An à la carte set of polished, extensible, and accessible inputs built for React
483 lines (438 loc) • 15.2 kB
JavaScript
const _excluded = ["id", "autoFocus", "textField", "dataKey", "value", "defaultValue", "onChange", "open", "defaultOpen", "onToggle", "searchTerm", "defaultSearchTerm", "onSearch", "filter", "allowCreate", "delay", "focusFirstItem", "className", "containerClassName", "placeholder", "busy", "disabled", "readOnly", "selectIcon", "busySpinner", "dropUp", "tabIndex", "popupTransition", "name", "autoComplete", "onSelect", "onCreate", "onKeyPress", "onKeyDown", "onClick", "inputProps", "listProps", "renderListItem", "renderListGroup", "optionComponent", "renderValue", "groupBy", "onBlur", "onFocus", "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 PropTypes from 'prop-types';
import React, { useImperativeHandle, useMemo, useRef, useState } from 'react';
import { useUncontrolledProp } from 'uncontrollable';
import useTimeout from '@restart/hooks/useTimeout';
import AddToListOption, { CREATE_OPTION } from './AddToListOption';
import DropdownListInput from './DropdownListInput';
import { caretDown } from './Icon';
import List from './List';
import { FocusListContext, useFocusList } from './FocusListContext';
import BasePopup from './Popup';
import Widget from './Widget';
import WidgetPicker from './WidgetPicker';
import { useMessagesWithDefaults } from './messages';
import { useActiveDescendant } from './A11y';
import { useFilteredData, presets } from './Filter';
import * as CustomPropTypes from './PropTypes';
import canShowCreate from './canShowCreate';
import { useAccessors } from './Accessors';
import useAutoFocus from './useAutoFocus';
import useDropdownToggle from './useDropdownToggle';
import useFocusManager from './useFocusManager';
import { notify, useFirstFocusedRender, useInstanceId } from './WidgetHelpers';
import PickerCaret from './PickerCaret';
const propTypes = {
value: PropTypes.any,
/**
* @type {function (
* dataItems: ?any,
* metadata: {
* lastValue: ?any,
* searchTerm: ?string
* originalEvent: SyntheticEvent,
* }
* ): void}
*/
onChange: PropTypes.func,
open: PropTypes.bool,
onToggle: PropTypes.func,
data: PropTypes.array,
dataKey: CustomPropTypes.accessor,
textField: CustomPropTypes.accessor,
allowCreate: PropTypes.oneOf([true, false, 'onFilter']),
/**
* A React render prop for customizing the rendering of the DropdownList
* value
*/
renderValue: PropTypes.func,
renderListItem: PropTypes.func,
listComponent: CustomPropTypes.elementType,
optionComponent: CustomPropTypes.elementType,
renderPopup: PropTypes.func,
renderListGroup: PropTypes.func,
groupBy: CustomPropTypes.accessor,
/**
*
* @type {(dataItem: ?any, metadata: { originalEvent: SyntheticEvent }) => void}
*/
onSelect: PropTypes.func,
onCreate: PropTypes.func,
/**
* @type function(searchTerm: string, metadata: { action, lastSearchTerm, originalEvent? })
*/
onSearch: PropTypes.func,
searchTerm: PropTypes.string,
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,
placeholder: PropTypes.string,
dropUp: PropTypes.bool,
popupTransition: CustomPropTypes.elementType,
disabled: CustomPropTypes.disabled.acceptsArray,
readOnly: CustomPropTypes.disabled,
/** Adds a css class to the input container element. */
containerClassName: PropTypes.string,
inputProps: PropTypes.object,
listProps: PropTypes.object,
messages: PropTypes.shape({
open: PropTypes.string,
emptyList: CustomPropTypes.message,
emptyFilter: CustomPropTypes.message,
createOption: CustomPropTypes.message
})
};
function useSearchWordBuilder(delay) {
const timeout = useTimeout();
const wordRef = useRef('');
function search(character, cb) {
let word = (wordRef.current + character).toLowerCase();
if (!character) return;
wordRef.current = word;
timeout.set(() => {
wordRef.current = '';
cb(word);
}, delay);
}
return search;
}
/**
* A `<select>` replacement for single value lists.
* @public
*/
const DropdownListImpl = /*#__PURE__*/React.forwardRef(function DropdownList(_ref, outerRef) {
let {
id,
autoFocus,
textField,
dataKey,
value,
defaultValue,
onChange,
open,
defaultOpen = false,
onToggle,
searchTerm,
defaultSearchTerm = '',
onSearch,
filter = true,
allowCreate = false,
delay = 500,
focusFirstItem,
className,
containerClassName,
placeholder,
busy,
disabled,
readOnly,
selectIcon = caretDown,
busySpinner,
dropUp,
tabIndex,
popupTransition,
name,
autoComplete,
onSelect,
onCreate,
onKeyPress,
onKeyDown,
onClick,
inputProps,
listProps,
renderListItem,
renderListGroup,
optionComponent,
renderValue,
groupBy,
onBlur,
onFocus,
listComponent: ListComponent = List,
popupComponent: Popup = BasePopup,
data: rawData = [],
messages: userMessages
} = _ref,
elementProps = _objectWithoutPropertiesLoose(_ref, _excluded);
const [currentValue, handleChange] = useUncontrolledProp(value, defaultValue, onChange);
const [currentOpen, handleOpen] = useUncontrolledProp(open, defaultOpen, onToggle);
const [currentSearch, handleSearch] = useUncontrolledProp(searchTerm, defaultSearchTerm, onSearch);
const ref = useRef(null);
const filterRef = useRef(null);
const listRef = useRef(null);
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);
useAutoFocus(!!autoFocus, ref);
const toggle = useDropdownToggle(currentOpen, handleOpen);
const isDisabled = disabled === true; // const disabledItems = toItemArray(disabled)
const isReadOnly = !!readOnly;
const [focusEvents, focused] = useFocusManager(ref, {
disabled: isDisabled,
onBlur,
onFocus
}, {
didHandle(focused) {
if (focused) {
if (filter) focus();
return;
}
toggle.close();
clearSearch();
}
});
const data = useFilteredData(rawData, currentOpen ? filter : false, currentSearch, 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 [autofilling, setAutofilling] = useState(false);
const nextSearchChar = useSearchWordBuilder(delay);
const focusedItem = list.getFocused();
useActiveDescendant(ref, activeId, focusedItem && currentOpen, [focusedItem]);
const showCreateOption = canShowCreate(allowCreate, {
searchTerm: currentSearch,
data,
accessors
});
const handleCreate = event => {
notify(onCreate, [currentSearch]);
clearSearch(event);
toggle.close();
focus();
};
const handleSelect = (dataItem, originalEvent) => {
if (readOnly || isDisabled) return;
if (dataItem === undefined) return;
originalEvent == null ? void 0 : originalEvent.preventDefault();
if (dataItem === CREATE_OPTION) {
handleCreate(originalEvent);
return;
}
notify(onSelect, [dataItem, {
originalEvent
}]);
change(dataItem, originalEvent, true);
toggle.close();
focus();
};
const handleClick = e => {
if (readOnly || isDisabled) return; // prevents double clicks when in a <label>
e.preventDefault();
focus();
toggle();
notify(onClick, [e]);
};
const handleKeyDown = e => {
if (readOnly || isDisabled) return;
let {
key,
altKey,
ctrlKey,
shiftKey
} = e;
notify(onKeyDown, [e]);
let closeWithFocus = () => {
clearSearch();
toggle.close();
if (currentOpen) setTimeout(focus);
};
if (e.defaultPrevented) return;
if (key === 'End' && currentOpen && !shiftKey) {
e.preventDefault();
list.focus(list.last());
} else if (key === 'Home' && currentOpen && !shiftKey) {
e.preventDefault();
list.focus(list.first());
} else if (key === 'Escape' && (currentOpen || currentSearch)) {
e.preventDefault();
closeWithFocus();
} else if (key === 'Enter' && currentOpen && ctrlKey && showCreateOption) {
e.preventDefault();
handleCreate(e);
} else if ((key === 'Enter' || key === ' ' && !filter) && currentOpen) {
e.preventDefault();
if (list.hasFocused()) handleSelect(list.getFocused(), e);
} else if (key === 'ArrowDown') {
e.preventDefault();
if (!currentOpen) {
toggle.open();
return;
}
list.focus(list.next());
} else if (key === 'ArrowUp') {
e.preventDefault();
if (altKey) return closeWithFocus();
list.focus(list.prev());
}
};
const handleKeyPress = e => {
if (readOnly || isDisabled) return;
notify(onKeyPress, [e]);
if (e.defaultPrevented || filter) return;
nextSearchChar(String.fromCharCode(e.which), word => {
if (!currentOpen) return;
let isValid = item => presets.startsWith(accessors.text(item).toLowerCase(), word.toLowerCase());
const [items, focusedItem] = list.get();
const len = items.length;
const startIdx = items.indexOf(focusedItem) + 1;
const offset = startIdx >= len ? 0 : startIdx;
let idx = 0;
let pointer = offset;
while (idx < len) {
pointer = (idx + offset) % len;
let item = items[pointer];
if (isValid(list.toDataItem(item))) break;
idx++;
}
if (idx === len) return;
list.focus(items[pointer]);
});
};
const handleInputChange = e => {
// hitting space to open
if (!currentOpen && !e.target.value.trim()) {
e.preventDefault();
} else {
search(e.target.value, e, 'input');
}
toggle.open();
};
const handleAutofillChange = e => {
let filledValue = e.target.value.toLowerCase();
if (filledValue === '') return void change(null);
for (const item of rawData) {
if (String(accessors.value(item)).toLowerCase() === filledValue || accessors.text(item).toLowerCase() === filledValue) {
change(item, e);
break;
}
}
};
function change(nextValue, originalEvent, selected = false) {
if (!accessors.matches(nextValue, currentValue)) {
notify(handleChange, [nextValue, {
originalEvent,
source: selected ? 'listbox' : 'input',
lastValue: currentValue,
searchTerm: currentSearch
}]);
clearSearch(originalEvent);
toggle.close();
}
}
function focus() {
if (filter) filterRef.current.focus();else ref.current.focus();
}
function clearSearch(originalEvent) {
search('', originalEvent, 'clear');
}
function search(nextSearchTerm, originalEvent, action = 'input') {
if (currentSearch !== nextSearchTerm) handleSearch(nextSearchTerm, {
action,
originalEvent,
lastSearchTerm: currentSearch
});
}
/**
* Render
*/
useImperativeHandle(outerRef, () => ({
focus
}));
let valueItem = accessors.findOrSelf(data, currentValue);
let shouldRenderPopup = useFirstFocusedRender(focused, currentOpen);
const widgetProps = Object.assign({}, elementProps, {
role: 'combobox',
id: inputId,
//tab index when there is no filter input to take focus
tabIndex: filter ? -1 : tabIndex || 0,
// FIXME: only when item exists
'aria-owns': listId,
'aria-expanded': !!currentOpen,
'aria-haspopup': true,
'aria-busy': !!busy,
'aria-live': currentOpen ? 'polite' : undefined,
'aria-autocomplete': 'list',
'aria-disabled': isDisabled,
'aria-readonly': isReadOnly
});
return /*#__PURE__*/React.createElement(FocusListContext.Provider, {
value: list.context
}, /*#__PURE__*/React.createElement(Widget, _extends({}, widgetProps, {
open: !!currentOpen,
dropUp: !!dropUp,
focused: !!focused,
disabled: isDisabled,
readOnly: isReadOnly,
autofilling: autofilling
}, focusEvents, {
onKeyDown: handleKeyDown,
onKeyPress: handleKeyPress,
className: cn(className, 'rw-dropdown-list'),
ref: ref
}), /*#__PURE__*/React.createElement(WidgetPicker, {
onClick: handleClick,
tabIndex: filter ? -1 : 0,
className: cn(containerClassName, 'rw-widget-input')
}, /*#__PURE__*/React.createElement(DropdownListInput, _extends({}, inputProps, {
value: valueItem,
dataKeyAccessor: accessors.value,
textAccessor: accessors.text,
name: name,
readOnly: readOnly,
disabled: isDisabled,
allowSearch: !!filter,
searchTerm: currentSearch,
ref: filterRef,
autoComplete: autoComplete,
onSearch: handleInputChange,
onAutofill: setAutofilling,
onAutofillChange: handleAutofillChange,
placeholder: placeholder,
renderValue: renderValue
})), /*#__PURE__*/React.createElement(PickerCaret, {
visible: true,
busy: busy,
icon: selectIcon,
spinner: busySpinner
})), shouldRenderPopup && /*#__PURE__*/React.createElement(Popup, {
dropUp: dropUp,
open: currentOpen,
transition: popupTransition,
onEntered: focus,
onEntering: () => listRef.current.scrollIntoView()
}, /*#__PURE__*/React.createElement(ListComponent, _extends({}, listProps, {
id: listId,
data: data,
tabIndex: -1,
disabled: disabled,
groupBy: groupBy,
searchTerm: currentSearch,
accessors: accessors,
renderItem: renderListItem,
renderGroup: renderListGroup,
optionComponent: optionComponent,
value: selectedItem,
onChange: (d, meta) => handleSelect(d, meta.originalEvent),
"aria-live": currentOpen ? 'polite' : undefined,
"aria-labelledby": inputId,
"aria-hidden": !currentOpen,
ref: listRef,
messages: {
emptyList: rawData.length ? messages.emptyFilter : messages.emptyList
}
})), showCreateOption && /*#__PURE__*/React.createElement(AddToListOption, {
onSelect: handleCreate
}, messages.createOption(currentValue, currentSearch || '')))));
});
DropdownListImpl.displayName = 'DropdownList';
DropdownListImpl.propTypes = propTypes;
export default DropdownListImpl;