use-item-list
Version:
Manage indexed collections in React using hooks.
450 lines (379 loc) • 13.3 kB
JavaScript
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