UNPKG

use-item-list

Version:

Manage indexed collections in React using hooks.

450 lines (379 loc) 13.3 kB
import { useRef, useCallback, useEffect, useState, useLayoutEffect } from 'react'; import useConstant from 'use-constant'; import mitt from 'mitt'; function scroller(node) { if (node === document.body) { return { offsetTop: 0, scrollY: window.pageYOffset, height: window.innerHeight, setPosition: function setPosition(top) { return window.scrollTo(0, top); } }; } else { return { offsetTop: node.getBoundingClientRect().top, scrollY: node.scrollTop, height: node.offsetHeight, setPosition: function setPosition(top) { return node.scrollTop = top; } }; } } /** * Get the closest element that scrolls * @param {HTMLElement} node - the child element to start searching for scroll parent at * @param {HTMLElement} rootNode - the root element of the component * @return {HTMLElement} the closest parentNode that scrolls */ function getClosestScrollParent(node) { if (node !== null) { if (node === document.body || node.scrollHeight > node.clientHeight) { return scroller(node); } else { return getClosestScrollParent(node.parentNode); } } else { return null; } } /** * Scroll node into view if necessary * @param {HTMLElement} node - the element that should scroll into view * @param {HTMLElement} rootNode - the root element of the component * @param {Boolean} alignToTop - align element to the top of the visible area of the scrollable ancestor */ function scrollIntoView(node) { var scrollParent = getClosestScrollParent(node); if (scrollParent === null) { return; } var nodeRect = node.getBoundingClientRect(); var nodeTop = scrollParent.scrollY + (nodeRect.top - scrollParent.offsetTop); if (nodeTop < scrollParent.scrollY) { // the item is above the scrollable area scrollParent.setPosition(nodeTop); } else if (nodeTop + nodeRect.height > scrollParent.scrollY + scrollParent.height) { // the item is below the scrollable area scrollParent.setPosition(nodeTop + nodeRect.height - scrollParent.height); } } var isServer = typeof window !== 'undefined'; var useIsomorphicEffect = isServer ? useEffect : useLayoutEffect; function isNumberKey(event) { return event.keyCode >= 48 && event.keyCode <= 57; } function isLetterKey(event) { return event.keyCode >= 65 && event.keyCode <= 90; } function isSpecialKey(event) { return event.keyCode >= 188 && event.keyCode <= 190; } function isComboKey(event) { return event.ctrlKey || event.metaKey || event.altKey; } function modulo(val, max) { return val >= 0 ? val % max : (val % max + max) % max; } function useForceUpdate() { var _useState = useState(), forceUpdate = _useState[1]; return useCallback(function () { return forceUpdate(Object.create(null)); }, []); } var localId = 0; function useItemList(_temp) { var _ref = _temp === void 0 ? {} : _temp, _ref$id = _ref.id, id = _ref$id === void 0 ? localId++ : _ref$id, _ref$initialHighlight = _ref.initialHighlightedIndex, initialHighlightedIndex = _ref$initialHighlight === void 0 ? 0 : _ref$initialHighlight, onHighlight = _ref.onHighlight, onSelect = _ref.onSelect, _ref$selected = _ref.selected, selected = _ref$selected === void 0 ? null : _ref$selected; var controllerId = useRef('controller-' + id); var listId = useRef('list-' + id); var getItemId = function getItemId(index) { return listId.current + ("-item-" + index); }; var itemListEmitter = useConstant(function () { return mitt(); }); var itemListForceUpdate = useForceUpdate(); var highlightedIndex = useRef(initialHighlightedIndex); var items = useRef([]); var nextItems = useRef([]); var shouldCollectItems = useRef(true); var invalidatedItems = useRef(false); var storeItem = useCallback(function (_ref2) { var ref = _ref2.ref, text = _ref2.text, value = _ref2.value, disabled = _ref2.disabled; var itemIndex = nextItems.current.findIndex(function (item) { return item.value === value; }); // First, we check if the incoming ref is new and // determine if the parent has set shouldCollectRefs or not. // If it hasn't, we need to refetch refs by their tree order if (itemIndex === -1 && nextItems.current.length > 0 && shouldCollectItems.current === false) { // stop collecting refs and start over in forced parent render // since the collection has been invalidated invalidatedItems.current = true; } else if (itemIndex === -1) { itemIndex = nextItems.current.length; nextItems.current.push({ id: getItemId(itemIndex), ref: ref, text: text, value: value, disabled: disabled }); } return itemIndex; }, []); // Clear nextItems on every render before collecting items nextItems.current = []; shouldCollectItems.current = true; useIsomorphicEffect(function () { // This effect runs after all children were stored, so we // can now apply the collected items to the items array items.current = nextItems.current; shouldCollectItems.current = false; }); // Select useEffect(function () { function handleSelect(selectedIndex) { var item = items.current[selectedIndex]; if (onSelect && item) { onSelect(item, selectedIndex); } } itemListEmitter.on('SELECT_ITEM', handleSelect); return function () { itemListEmitter.off('SELECT_ITEM', handleSelect); }; }, [onSelect]); var selectedRef = useRef(null); selectedRef.current = selected; function isItemSelected(value) { return typeof selectedRef.current === 'function' ? selectedRef.current(value) : selectedRef.current === value; } // Highlight function setHighlightedItem(index) { highlightedIndex.current = index; itemListEmitter.emit('HIGHLIGHT_ITEM', index); if (onHighlight) { onHighlight(items.current[index], index); } } function moveHighlightedItem(amount, _temp2) { var _ref3 = _temp2 === void 0 ? {} : _temp2, _ref3$contain = _ref3.contain, contain = _ref3$contain === void 0 ? true : _ref3$contain; var itemCount = items.current.length; var index = highlightedIndex.current; if (index === null) { index = amount >= 0 ? 0 : itemCount - 1; } else { var getNextIndex = function getNextIndex(index) { var nextIndex = index + amount; if (nextIndex < 0 || nextIndex >= itemCount) { nextIndex = contain ? modulo(highlightedIndex.current + amount, itemCount) : null; } var item = items.current[nextIndex]; if (item.disabled) { nextIndex = getNextIndex(nextIndex); } return nextIndex; }; index = getNextIndex(index); } setHighlightedItem(index); } function clearHighlightedItem() { setHighlightedItem(null); } function selectHighlightedItem() { if (highlightedIndex.current !== null) { itemListEmitter.emit('SELECT_ITEM', highlightedIndex.current); } } function getHighlightedIndex() { return highlightedIndex.current; } function getHighlightedItem() { var _items$current$highli; return (_items$current$highli = items.current[highlightedIndex.current]) != null ? _items$current$highli : null; } function getHighlightedItemId(index) { var _items$current$index$, _items$current$index; return (_items$current$index$ = (_items$current$index = items.current[index]) == null ? void 0 : _items$current$index.id) != null ? _items$current$index$ : null; } var useHighlightedItemId = useCallback(function () { var _useState2 = useState(function () { return getHighlightedItemId(highlightedIndex.current); }), highlightedItemId = _useState2[0], setHighlightedItemId = _useState2[1]; useEffect(function () { function handleHighlight(newIndex) { setHighlightedItemId(getHighlightedItemId(newIndex)); } itemListEmitter.on('HIGHLIGHT_ITEM', handleHighlight); return function () { itemListEmitter.off('HIGHLIGHT_ITEM', handleHighlight); }; }, []); return highlightedItemId; }, []); // String Search var searchString = useRef(''); var searchStringTimer = useRef(null); function highlightItemByString(event, delay) { if (delay === void 0) { delay = 300; } if ((isNumberKey(event) || isLetterKey(event) || isSpecialKey(event)) && !isComboKey(event)) { event.preventDefault(); addToSearchString(event.key); startSearchStringTimer(delay); highlightItemFromString(searchString.current); } } function addToSearchString(letter) { searchString.current += letter.toLowerCase(); } function clearSearchString() { searchString.current = ''; } function startSearchStringTimer(delay) { clearSearchStringTimer(); searchStringTimer.current = setTimeout(function () { clearSearchString(); }, delay); } function clearSearchStringTimer() { clearTimeout(searchStringTimer.current); } function highlightItemFromString(text) { for (var index = 0; index < items.current.length; index++) { var item = items.current[index]; var itemText = item.text || String(item.value); if (itemText.toLowerCase().indexOf(text) === 0) { setHighlightedItem(index); break; } } } var useItem = useCallback(function (_ref4) { var ref = _ref4.ref, text = _ref4.text, value = _ref4.value, _ref4$disabled = _ref4.disabled, disabled = _ref4$disabled === void 0 ? false : _ref4$disabled; var itemEmitter = useConstant(function () { return mitt(); }); var itemForceUpdate = useForceUpdate(); var itemIndex = storeItem({ ref: ref, text: text, value: value, disabled: disabled }); var itemIndexRef = useRef(itemIndex); function highlight() { if (disabled === false) { setHighlightedItem(itemIndexRef.current); } } function select() { if (disabled === false) { itemListEmitter.emit('SELECT_ITEM', itemIndexRef.current); } } useIsomorphicEffect(function () { // if collection was invalidated, we need to trigger an update in the parent if (invalidatedItems.current) { itemListForceUpdate(); invalidatedItems.current = false; } }); useIsomorphicEffect(function () { // patch the index if new children were added if (itemIndexRef.current !== itemIndex) { itemIndexRef.current = itemIndex; itemForceUpdate(); } itemEmitter.emit('UPDATE_ITEM_INDEX', itemIndex); }, [itemIndex]); useEffect(function () { function handleHighlight(newIndex) { if (itemIndexRef.current === newIndex) { var itemNode = ref == null ? void 0 : ref.current; if (itemNode) { scrollIntoView(itemNode); } } } itemListEmitter.on('HIGHLIGHT_ITEM', handleHighlight); return function () { itemListEmitter.off('HIGHLIGHT_ITEM', handleHighlight); }; }, []); var useHighlighted = useCallback(function () { var _useState3 = useState(null), highlighted = _useState3[0], setHighlighted = _useState3[1]; useIsomorphicEffect(function () { function handleHighlight(newIndex) { setHighlighted(itemIndexRef.current === newIndex); } function handleIndex(itemIndex) { // update the highlight state in case of a mismatch var nextHighlighted = highlightedIndex.current === itemIndex; if (highlighted !== nextHighlighted) { setHighlighted(nextHighlighted); } } itemListEmitter.on('HIGHLIGHT_ITEM', handleHighlight); itemEmitter.on('UPDATE_ITEM_INDEX', handleIndex); return function () { itemListEmitter.off('HIGHLIGHT_ITEM', handleHighlight); itemEmitter.off('UPDATE_ITEM_INDEX', handleIndex); }; }, []); useIsomorphicEffect(function () { var nextHighlighted = itemIndexRef.current === highlightedIndex.current; if (highlighted !== nextHighlighted) { setHighlighted(nextHighlighted); } }); return highlighted; }, []); return { id: getItemId(itemIndex), index: itemIndexRef.current, highlight: highlight, select: select, selected: isItemSelected(value), useHighlighted: useHighlighted }; }, []); return { controllerId: controllerId.current, listId: listId.current, items: items, getHighlightedIndex: getHighlightedIndex, getHighlightedItem: getHighlightedItem, setHighlightedItem: setHighlightedItem, moveHighlightedItem: moveHighlightedItem, clearHighlightedItem: clearHighlightedItem, selectHighlightedItem: selectHighlightedItem, useHighlightedItemId: useHighlightedItemId, highlightItemByString: highlightItemByString, useItem: useItem }; } export { useItemList }; //# sourceMappingURL=use-item-list.esm.js.map