UNPKG

react-widgets-up

Version:

An à la carte set of polished, extensible, and accessible inputs built for React

437 lines (429 loc) 15.2 kB
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", "popupProps", "renderListItem", "renderListGroup", "renderTagValue", "optionComponent", "tagOptionComponent", "groupBy", "listComponent", "popupComponent", "tagListComponent", "data", "messages"]; 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 closest from 'dom-helpers/closest'; import * as React from 'react'; import { useImperativeHandle, useMemo, useRef, useEffect } from 'react'; import { useUncontrolledProp } from 'uncontrollable'; import AddToListOption, { CREATE_OPTION } from './AddToListOption'; import { times } from './Icon'; import List from './List'; import { FocusListContext, useFocusList } from './FocusListContext'; import MultiselectInput from './MultiselectInput'; import MultiselectTagList from './MultiselectTagList'; import BasePopup from './Popup'; import Widget from './Widget'; import WidgetPicker from './WidgetPicker'; import { useMessagesWithDefaults } from './messages'; import { setActiveDescendant } from './A11y'; import { useFilteredData } from './Filter'; import canShowCreate from './canShowCreate'; import { useAccessors } from './Accessors'; import useDropdownToggle from './useDropdownToggle'; import useFocusManager from './useFocusManager'; import { notify, useFirstFocusedRender, useInstanceId } from './WidgetHelpers'; import DropdownCaret from './PickerCaret'; const ENTER = 13; const INSERT = 'insert'; const REMOVE = 'remove'; const EMPTY_ARRAY = []; function useMultiselectData(value = EMPTY_ARRAY, data, accessors, filter, searchTerm, showSelectedItemsInList) { data = useMemo(() => showSelectedItemsInList ? data : data.filter(i => !value.some(v => accessors.matches(i, v))), [data, showSelectedItemsInList, value, accessors]); return [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.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 = times, busySpinner, dropUp, tabIndex, popupTransition, showPlaceholderWithValues = false, showSelectedItemsInList = false, onSelect, onCreate, onKeyDown, onBlur, onFocus, inputProps, listProps, popupProps, renderListItem, renderListGroup, renderTagValue, optionComponent, tagOptionComponent, groupBy, listComponent: ListComponent = List, popupComponent: Popup = BasePopup, tagListComponent: TagList = MultiselectTagList, data: rawData = [], messages: userMessages } = _ref, elementProps = _objectWithoutPropertiesLoose(_ref, _excluded); let [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 inputRef = useRef(null); const listRef = useRef(null); const inputId = useInstanceId(id, '_input'); const tagsId = useInstanceId(id, '_taglist'); const listId = useInstanceId(id, '_listbox'); const createId = useInstanceId(id, '_createlist_option'); const activeTagId = useInstanceId(id, '_taglist_active_tag'); const activeOptionId = 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 [focusEvents, focused] = useFocusManager(ref, { disabled: isDisabled, onBlur, onFocus }, { didHandle(focused, event) { if (focused) return focus(); toggle.close(); clearSearch(event); tagList.focus(null); } }); const dataItems = 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 = useFocusList({ scope: ref, scopeSelector: '.rw-popup', focusFirstItem, activeId: activeOptionId, anchorItem: currentOpen ? dataItems[dataItems.length - 1] : undefined }); const tagList = useFocusList({ scope: ref, scopeSelector: '.rw-multiselect-taglist', activeId: activeTagId }); const showCreateOption = canShowCreate(allowCreate, { searchTerm: currentSearch, data, dataItems, accessors }); /** * Update aria when it changes on update */ const focusedTag = tagList.getFocused(); useEffect(() => { if (currentOpen) return; setActiveDescendant(inputRef.current, focusedTag ? activeTagId : ''); }, [activeTagId, currentOpen, focusedTag]); const focusedItem = list.getFocused(); useEffect(() => { if (!currentOpen) return; // if (focusedItem) tagList.focus(null) 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 = 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 (closest(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 === CREATE_OPTION) { handleCreate(originalEvent); return; } notify(onSelect, [dataItem, { originalEvent }]); if (!showSelectedItemsInList || !dataItems.includes(dataItem)) { change(dataItem, originalEvent, INSERT); } else { change(dataItem, originalEvent, REMOVE); } focus(); }; const handleCreate = event => { notify(onCreate, [currentSearch]); clearSearch(event); focus(); }; const handleKeyDown = event => { if (readOnly) { event.preventDefault(); return; } let { key, keyCode, altKey, ctrlKey } = event; 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 */ useImperativeHandle(outerRef, () => ({ focus })); let shouldRenderPopup = useFirstFocusedRender(focused, currentOpen); let shouldRenderTags = !!dataItems.length; let inputOwns = `${listId} ` + (shouldRenderTags ? tagsId : '') + (showCreateOption ? createId : ''); return /*#__PURE__*/React.createElement(Widget, _extends({}, elementProps, { ref: ref, open: currentOpen, dropUp: dropUp, focused: focused, disabled: isDisabled, readOnly: isReadOnly, onKeyDown: handleKeyDown }, focusEvents, { className: cn(className, 'rw-multiselect') }), /*#__PURE__*/React.createElement(WidgetPicker, { onClick: handleClick, onTouchEnd: handleClick, onDoubleClick: handleDoubleClick, className: cn(containerClassName, 'rw-widget-input') }, /*#__PURE__*/React.createElement(FocusListContext.Provider, { value: tagList.context }, /*#__PURE__*/React.createElement(TagList, { id: tagsId, textAccessor: accessors.text, clearTagIcon: clearTagIcon, label: messages.tagsLabel(), value: dataItems, readOnly: isReadOnly, disabled: disabled, onDelete: handleDelete, tagOptionComponent: tagOptionComponent, renderTagValue: renderTagValue }, /*#__PURE__*/React.createElement(MultiselectInput, _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.createElement(DropdownCaret, { busy: busy, spinner: busySpinner, icon: selectIcon, visible: focused })), /*#__PURE__*/React.createElement(FocusListContext.Provider, { value: list.context }, shouldRenderPopup && /*#__PURE__*/React.createElement(Popup, _extends({}, popupProps, { dropUp: dropUp, open: currentOpen, transition: popupTransition, onEntering: () => listRef.current.scrollIntoView() }), /*#__PURE__*/React.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.createElement(AddToListOption, { onSelect: handleCreate }, messages.createOption(currentValue, currentSearch))))); }); Multiselect.displayName = 'Multiselect'; export default Multiselect;