react-widgets-up
Version:
An à la carte set of polished, extensible, and accessible inputs built for React
382 lines (379 loc) • 13.3 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", "popupProps", "renderListItem", "renderListGroup", "optionComponent", "renderValue", "groupBy", "onBlur", "onFocus", "listComponent", "popupComponent", "data", "messages", "ref"];
function _extends() { return _extends = Object.assign ? Object.assign.bind() : function (n) { for (var e = 1; e < arguments.length; e++) { var t = arguments[e]; for (var r in t) ({}).hasOwnProperty.call(t, r) && (n[r] = t[r]); } return n; }, _extends.apply(null, arguments); }
function _objectWithoutPropertiesLoose(r, e) { if (null == r) return {}; var t = {}; for (var n in r) if ({}.hasOwnProperty.call(r, n)) { if (-1 !== e.indexOf(n)) continue; t[n] = r[n]; } return t; }
import cn from 'classnames';
import { useImperativeHandle, useMemo, useRef, useState } from 'react';
import * as React 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 { useActiveDescendant } from './A11y';
import { useFilteredData, presets } from './Filter';
import canShowCreate from './canShowCreate';
import { useAccessors } from './Accessors';
import useAutoFocus from './useAutoFocus';
import useDropdownToggle from './useDropdownToggle';
import useFocusManager from './useFocusManager';
import { useLocalizer } from './Localization';
import { notify, useFirstFocusedRender, useInstanceId } from './WidgetHelpers';
import PickerCaret from './PickerCaret';
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
*/
function DropdownListImpl(_ref) {
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,
popupProps,
renderListItem,
renderListGroup,
optionComponent,
renderValue,
groupBy,
onBlur,
onFocus,
listComponent: ListComponent = List,
popupComponent: Popup = BasePopup,
data: rawData = [],
messages: userMessages,
ref: outerRef
} = _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 localizer = useLocalizer(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 || 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': elementProps['aria-owns'] ? `${listId} ${elementProps['aria-owns']}` : listId,
'aria-controls': elementProps['aria-controls'] ? `${listId} ${elementProps['aria-controls']}` : listId,
'aria-expanded': !!currentOpen,
'aria-haspopup': 'listbox',
'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, _extends({}, popupProps, {
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 ? localizer.messages.emptyFilter : localizer.messages.emptyList
}
})), showCreateOption && /*#__PURE__*/React.createElement(AddToListOption, {
onSelect: handleCreate
}, localizer.messages.createOption(currentValue, currentSearch || '')))));
}
DropdownListImpl.displayName = 'DropdownList';
export default DropdownListImpl;