react-select-async-paginate
Version:
Wrapper above react-select that supports pagination on menu scroll
499 lines (482 loc) • 14.5 kB
JavaScript
// src/index.ts
import Select from "react-select";
// src/components/useComponents.ts
import { useMemo as useMemo2 } from "react";
import { components as defaultComponents } from "react-select";
// src/components/wrapMenuList.tsx
import composeRefs from "@seznam/compose-react-refs";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { jsx } from "react/jsx-runtime";
var CHECK_TIMEOUT = 300;
function wrapMenuList(MenuList2) {
function WrappedMenuList(props) {
const { selectProps, innerRef } = props;
const { handleScrolledToBottom, shouldLoadMore } = selectProps;
const checkTimeoutRef = useRef(null);
const menuListRef = useRef(null);
const shouldHandle = useCallback(() => {
const el = menuListRef.current;
if (!el) {
return false;
}
const { scrollTop, scrollHeight, clientHeight } = el;
return shouldLoadMore(scrollHeight, clientHeight, scrollTop);
}, [shouldLoadMore]);
const checkAndHandle = useCallback(() => {
if (shouldHandle()) {
if (handleScrolledToBottom) {
handleScrolledToBottom();
}
}
}, [shouldHandle, handleScrolledToBottom]);
const setCheckAndHandleTimeout = useMemo(() => {
const res = () => {
checkAndHandle();
checkTimeoutRef.current = setTimeout(
res,
CHECK_TIMEOUT
);
};
return res;
}, [checkAndHandle]);
useEffect(() => {
setCheckAndHandleTimeout();
return () => {
if (checkTimeoutRef.current) {
clearTimeout(checkTimeoutRef.current);
}
};
}, []);
return /* @__PURE__ */ jsx(
MenuList2,
{
...props,
innerRef: composeRefs(innerRef, menuListRef)
}
);
}
return WrappedMenuList;
}
// src/components/useComponents.ts
var MenuList = wrapMenuList(
// biome-ignore lint/suspicious/noExplicitAny: fix types
defaultComponents.MenuList
);
var useComponents = (components) => useMemo2(
() => ({
MenuList,
...components
}),
[components]
);
// src/useAsyncPaginate.ts
import { useCallback as useCallback3, useState as useState2 } from "react";
// src/useAsyncPaginateBase.ts
import { useLazyRef } from "@vtaits/use-lazy-ref";
import { useCallback as useCallback2, useEffect as useEffect2, useMemo as useMemo3, useRef as useRef2, useState } from "react";
import useIsMountedRef from "use-is-mounted-ref";
import useLatest from "use-latest";
// src/defaultReduceOptions.ts
var defaultReduceOptions = (prevOptions, loadedOptions) => [...prevOptions, ...loadedOptions];
// src/defaultShouldLoadMore.ts
var AVAILABLE_DELTA = 10;
var defaultShouldLoadMore = (scrollHeight, clientHeight, scrollTop) => {
const bottomBorder = scrollHeight - clientHeight - AVAILABLE_DELTA;
return bottomBorder < scrollTop;
};
// src/getInitialCache.ts
var getInitialCache = (params) => ({
isFirstLoad: true,
options: [],
hasMore: true,
isLoading: false,
lockedUntil: 0,
additional: params.additional
});
// src/getInitialOptionsCache.ts
var getInitialOptionsCache = ({
options,
defaultOptions,
additional,
defaultAdditional
}) => {
const initialOptions = defaultOptions === true ? null : Array.isArray(defaultOptions) ? defaultOptions : options;
if (initialOptions) {
return {
"": {
isFirstLoad: false,
isLoading: false,
options: initialOptions,
hasMore: true,
lockedUntil: 0,
additional: defaultAdditional || additional
}
};
}
return {};
};
// src/requestOptions.ts
import { getResult } from "krustykrab";
import sleep from "sleep-promise";
// src/validateResponse.ts
var errorText = '[react-select-async-paginate] response of "loadOptions" should be an object with "options" prop, which contains array of options.';
var checkIsResponse = (response) => {
if (!response) {
return false;
}
const { options, hasMore } = response;
if (!Array.isArray(options)) {
return false;
}
if (typeof hasMore !== "boolean" && typeof hasMore !== "undefined") {
return false;
}
return true;
};
var validateResponse = (response) => {
if (!checkIsResponse(response)) {
console.error(errorText, "Received:", response);
throw new Error(errorText);
}
return true;
};
// src/requestOptions.ts
var requestOptions = async (caller, paramsRef, optionsCacheRef, debounceTimeout, setOptionsCache, reduceOptions, isMountedRef, clearCacheOnSearchChange) => {
const currentInputValue = paramsRef.current.inputValue;
const isCacheEmpty = !optionsCacheRef.current[currentInputValue];
const currentOptions = isCacheEmpty ? getInitialCache(paramsRef.current) : optionsCacheRef.current[currentInputValue];
if (currentOptions.isLoading || !currentOptions.hasMore || currentOptions.lockedUntil > Date.now()) {
return;
}
setOptionsCache(
(prevOptionsCache) => {
if (clearCacheOnSearchChange && caller === "input-change") {
return {
[currentInputValue]: {
...currentOptions,
isLoading: true
}
};
}
return {
...prevOptionsCache,
[currentInputValue]: {
...currentOptions,
isLoading: true
}
};
}
);
if (debounceTimeout > 0 && caller === "input-change") {
await sleep(debounceTimeout);
const newInputValue = paramsRef.current.inputValue;
if (currentInputValue !== newInputValue) {
setOptionsCache((prevOptionsCache) => {
if (isCacheEmpty) {
const { [currentInputValue]: _itemForDelete, ...restCache } = prevOptionsCache;
return restCache;
}
return {
...prevOptionsCache,
[currentInputValue]: {
...currentOptions,
isLoading: false
}
};
});
return;
}
}
const { loadOptions, reloadOnErrorTimeout = 0 } = paramsRef.current;
const result = await getResult(
Promise.resolve().then(
() => loadOptions(
currentInputValue,
currentOptions.options,
currentOptions.additional
)
)
);
if (!isMountedRef.current) {
return;
}
if (result.isErr()) {
setOptionsCache((prevOptionsCache) => ({
...prevOptionsCache,
[currentInputValue]: {
...currentOptions,
isLoading: false,
lockedUntil: Date.now() + reloadOnErrorTimeout
}
}));
return;
}
const response = result.unwrap();
if (validateResponse(response)) {
const { options, hasMore } = response;
const newAdditional = Object.hasOwn(response, "additional") ? response.additional : currentOptions.additional;
setOptionsCache((prevOptionsCache) => ({
...prevOptionsCache,
[currentInputValue]: {
...currentOptions,
options: reduceOptions(currentOptions.options, options, newAdditional),
hasMore: !!hasMore,
isLoading: false,
isFirstLoad: false,
additional: newAdditional
}
}));
}
};
// src/useAsyncPaginateBase.ts
var increaseStateId = (prevStateId) => prevStateId + 1;
var useAsyncPaginateBase = (params, deps = []) => {
const {
clearCacheOnSearchChange = false,
clearCacheOnMenuClose = false,
defaultOptions,
loadOptionsOnMenuOpen = true,
debounceTimeout = 0,
inputValue,
menuIsOpen,
filterOption = null,
reduceOptions = defaultReduceOptions,
shouldLoadMore = defaultShouldLoadMore,
mapOptionsForMenu = void 0
} = params;
const menuIsOpenRef = useLatest(menuIsOpen);
const isMountedRef = useIsMountedRef();
const reduceOptionsRef = useLatest(reduceOptions);
const loadOptionsOnMenuOpenRef = useLatest(loadOptionsOnMenuOpen);
const isInitRef = useRef2(true);
const paramsRef = useRef2(params);
paramsRef.current = params;
const [_stateId, setStateId] = useState(0);
const optionsCacheRef = useLazyRef(() => getInitialOptionsCache(params));
const callRequestOptionsRef = useLatest(
(caller) => {
requestOptions(
caller,
paramsRef,
optionsCacheRef,
debounceTimeout,
(reduceState) => {
optionsCacheRef.current = reduceState(optionsCacheRef.current);
if (isMountedRef.current) {
setStateId(increaseStateId);
}
},
reduceOptionsRef.current,
isMountedRef,
clearCacheOnSearchChange
);
}
);
const handleScrolledToBottom = useCallback2(() => {
const currentInputValue = paramsRef.current.inputValue;
const currentOptions2 = optionsCacheRef.current[currentInputValue];
if (currentOptions2) {
callRequestOptionsRef.current("menu-scroll");
}
}, [callRequestOptionsRef, optionsCacheRef]);
useEffect2(() => {
if (isInitRef.current) {
isInitRef.current = false;
} else {
optionsCacheRef.current = {};
setStateId(increaseStateId);
}
if (defaultOptions === true) {
callRequestOptionsRef.current("autoload");
}
}, deps);
useEffect2(() => {
if (menuIsOpenRef.current && !optionsCacheRef.current[inputValue]) {
callRequestOptionsRef.current("input-change");
}
}, [callRequestOptionsRef, inputValue, menuIsOpenRef, optionsCacheRef]);
useEffect2(() => {
if (menuIsOpen) {
if (!optionsCacheRef.current[""] && loadOptionsOnMenuOpenRef.current) {
callRequestOptionsRef.current("menu-toggle");
return;
}
return;
}
if (clearCacheOnMenuClose) {
optionsCacheRef.current = {};
setStateId(increaseStateId);
}
}, [
callRequestOptionsRef,
loadOptionsOnMenuOpenRef,
menuIsOpen,
optionsCacheRef,
clearCacheOnMenuClose
]);
const currentOptions = optionsCacheRef.current[inputValue] || getInitialCache(params);
const options = useMemo3(() => {
if (!mapOptionsForMenu) {
return currentOptions.options;
}
return mapOptionsForMenu(currentOptions.options);
}, [currentOptions.options, mapOptionsForMenu]);
return {
handleScrolledToBottom,
shouldLoadMore,
filterOption,
isLoading: currentOptions.isLoading || currentOptions.lockedUntil > Date.now(),
isFirstLoad: currentOptions.isFirstLoad,
options
};
};
// src/useAsyncPaginate.ts
var useAsyncPaginate = (params, deps = []) => {
const {
inputValue: inputValueParam,
menuIsOpen: menuIsOpenParam,
defaultInputValue: defaultInputValueParam,
defaultMenuIsOpen: defaultMenuIsOpenParam,
onInputChange: onInputChangeParam,
onMenuClose: onMenuCloseParam,
onMenuOpen: onMenuOpenParam
} = params;
const [inputValueState, setInputValue] = useState2(
defaultInputValueParam || ""
);
const [menuIsOpenState, setMenuIsOpen] = useState2(!!defaultMenuIsOpenParam);
const inputValue = typeof inputValueParam === "string" ? inputValueParam : inputValueState;
const menuIsOpen = typeof menuIsOpenParam === "boolean" ? menuIsOpenParam : menuIsOpenState;
const onInputChange = useCallback3(
(nextInputValue, actionMeta) => {
if (onInputChangeParam) {
onInputChangeParam(nextInputValue, actionMeta);
}
setInputValue(nextInputValue);
},
[onInputChangeParam]
);
const onMenuClose = useCallback3(() => {
if (onMenuCloseParam) {
onMenuCloseParam();
}
setMenuIsOpen(false);
}, [onMenuCloseParam]);
const onMenuOpen = useCallback3(() => {
if (onMenuOpenParam) {
onMenuOpenParam();
}
setMenuIsOpen(true);
}, [onMenuOpenParam]);
const baseResult = useAsyncPaginateBase(
{
...params,
inputValue,
menuIsOpen
},
deps
);
return {
...baseResult,
inputValue,
menuIsOpen,
onInputChange,
onMenuClose,
onMenuOpen
};
};
// src/withAsyncPaginate.tsx
import { jsx as jsx2 } from "react/jsx-runtime";
var defaultCacheUniqs = [];
var defaultComponents2 = {};
function withAsyncPaginate(SelectComponent) {
function WithAsyncPaginate(props) {
const {
components = defaultComponents2,
selectRef = void 0,
isLoading: isLoadingProp,
cacheUniqs = defaultCacheUniqs,
menuPlacement,
menuShouldScrollIntoView,
...rest
} = props;
const asyncPaginateProps = useAsyncPaginate(rest, cacheUniqs);
const processedComponents = useComponents(
components
);
const isLoading = typeof isLoadingProp === "boolean" ? isLoadingProp : asyncPaginateProps.isLoading;
return /* @__PURE__ */ jsx2(
SelectComponent,
{
...props,
...asyncPaginateProps,
menuPlacement,
menuShouldScrollIntoView: menuPlacement === "auto" ? isLoading ? false : menuShouldScrollIntoView : menuShouldScrollIntoView,
isLoading,
components: processedComponents,
ref: selectRef
}
);
}
return WithAsyncPaginate;
}
// src/reduceGroupedOptions.ts
var checkGroup = (group) => {
if (!group) {
return false;
}
const { label, options } = group;
if (typeof label !== "string" && typeof label !== "undefined") {
return false;
}
if (!Array.isArray(options)) {
return false;
}
return true;
};
var reduceGroupedOptions = (prevOptions, loadedOptions) => {
const res = prevOptions.slice();
const mapLabelToIndex = {};
let prevOptionsIndex = 0;
const prevOptionsLength = prevOptions.length;
for (const optionOrGroup of loadedOptions) {
const group = checkGroup(optionOrGroup) ? optionOrGroup : {
options: [optionOrGroup]
};
const { label = "" } = group;
let groupIndex = mapLabelToIndex[label];
if (typeof groupIndex !== "number") {
for (; prevOptionsIndex < prevOptionsLength && typeof mapLabelToIndex[label] !== "number"; ++prevOptionsIndex) {
const prevGroup = prevOptions[prevOptionsIndex];
if (checkGroup(prevGroup)) {
mapLabelToIndex[prevGroup.label || ""] = prevOptionsIndex;
}
}
groupIndex = mapLabelToIndex[label];
}
if (typeof groupIndex !== "number") {
mapLabelToIndex[label] = res.length;
res.push(group);
} else {
res[groupIndex] = {
...res[groupIndex],
options: [...res[groupIndex].options, ...group.options]
};
}
}
return res;
};
// src/index.ts
var AsyncPaginate = withAsyncPaginate(Select);
export {
AsyncPaginate,
checkIsResponse,
reduceGroupedOptions,
useAsyncPaginate,
useAsyncPaginateBase,
useComponents,
validateResponse,
withAsyncPaginate,
wrapMenuList
};
//# sourceMappingURL=index.js.map