Use pagination instead of infinite scrolling
idk this has been a long-time popular request, so I'm just gonna like. throw it all the way out there. and see what people think of it I'm a bit worried it might change up the mobile experience too much? But like. let's find out!
This commit is contained in:
parent
ca58bc1be3
commit
4cdfff03d1
4 changed files with 110 additions and 200 deletions
|
@ -303,17 +303,21 @@ function LinkOrButton({ href, component = Button, ...props }) {
|
||||||
* ItemListContainer is a container for Item components! Wrap your Item
|
* ItemListContainer is a container for Item components! Wrap your Item
|
||||||
* components in this to ensure a consistent list layout.
|
* components in this to ensure a consistent list layout.
|
||||||
*/
|
*/
|
||||||
export function ItemListContainer({ children }) {
|
export function ItemListContainer({ children, ...props }) {
|
||||||
return <Flex direction="column">{children}</Flex>;
|
return (
|
||||||
|
<Flex direction="column" {...props}>
|
||||||
|
{children}
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ItemListSkeleton is a placeholder for when an ItemListContainer and its
|
* ItemListSkeleton is a placeholder for when an ItemListContainer and its
|
||||||
* Items are loading.
|
* Items are loading.
|
||||||
*/
|
*/
|
||||||
export function ItemListSkeleton({ count }) {
|
export function ItemListSkeleton({ count, ...props }) {
|
||||||
return (
|
return (
|
||||||
<ItemListContainer>
|
<ItemListContainer {...props}>
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
<ItemSkeleton key={i} />
|
<ItemSkeleton key={i} />
|
||||||
))}
|
))}
|
||||||
|
|
|
@ -38,7 +38,7 @@ function ItemsAndSearchPanels({
|
||||||
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
<Sentry.ErrorBoundary fallback={MajorErrorMessage}>
|
||||||
<TestErrorSender />
|
<TestErrorSender />
|
||||||
<Flex direction="column" height="100%">
|
<Flex direction="column" height="100%">
|
||||||
<Box px="5" py="3" boxShadow="sm">
|
<Box paddingX="5" paddingTop="3" paddingBottom="2" boxShadow="sm">
|
||||||
<SearchToolbar
|
<SearchToolbar
|
||||||
query={searchQuery}
|
query={searchQuery}
|
||||||
searchQueryRef={searchQueryRef}
|
searchQueryRef={searchQueryRef}
|
||||||
|
@ -49,30 +49,23 @@ function ItemsAndSearchPanels({
|
||||||
{!searchQueryIsEmpty(searchQuery) ? (
|
{!searchQueryIsEmpty(searchQuery) ? (
|
||||||
<Box
|
<Box
|
||||||
key="search-panel"
|
key="search-panel"
|
||||||
gridArea="items"
|
flex="1 0 0"
|
||||||
position="relative"
|
position="relative"
|
||||||
overflow="auto"
|
overflowY="scroll"
|
||||||
ref={scrollContainerRef}
|
ref={scrollContainerRef}
|
||||||
data-test-id="search-panel-scroll-container"
|
data-test-id="search-panel-scroll-container"
|
||||||
>
|
>
|
||||||
<Box px="4" py="2">
|
<SearchPanel
|
||||||
<SearchPanel
|
query={searchQuery}
|
||||||
query={searchQuery}
|
outfitState={outfitState}
|
||||||
outfitState={outfitState}
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
scrollContainerRef={scrollContainerRef}
|
||||||
scrollContainerRef={scrollContainerRef}
|
searchQueryRef={searchQueryRef}
|
||||||
searchQueryRef={searchQueryRef}
|
firstSearchResultRef={firstSearchResultRef}
|
||||||
firstSearchResultRef={firstSearchResultRef}
|
/>
|
||||||
/>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box
|
<Box position="relative" overflow="auto" key="items-panel">
|
||||||
gridArea="items"
|
|
||||||
position="relative"
|
|
||||||
overflow="auto"
|
|
||||||
key="items-panel"
|
|
||||||
>
|
|
||||||
<Box px="4" py="2">
|
<Box px="4" py="2">
|
||||||
<ItemsPanel
|
<ItemsPanel
|
||||||
loading={loading}
|
loading={loading}
|
||||||
|
|
|
@ -1,12 +1,15 @@
|
||||||
import React from "react";
|
import React from "react";
|
||||||
import gql from "graphql-tag";
|
import gql from "graphql-tag";
|
||||||
import { Box, Text, VisuallyHidden } from "@chakra-ui/react";
|
import { Box, Text, useColorModeValue, VisuallyHidden } from "@chakra-ui/react";
|
||||||
import { useQuery } from "@apollo/client";
|
import { useQuery } from "@apollo/client";
|
||||||
|
|
||||||
import { Delay, useDebounce } from "../util";
|
import { useDebounce } from "../util";
|
||||||
import { emptySearchQuery } from "./SearchToolbar";
|
import { emptySearchQuery } from "./SearchToolbar";
|
||||||
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
||||||
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
|
import { itemAppearanceFragment } from "../components/useOutfitAppearance";
|
||||||
|
import PaginationToolbar from "../components/PaginationToolbar";
|
||||||
|
|
||||||
|
const SEARCH_PER_PAGE = 30;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* SearchPanel shows item search results to the user, so they can preview them
|
* SearchPanel shows item search results to the user, so they can preview them
|
||||||
|
@ -50,19 +53,15 @@ function SearchPanel({
|
||||||
>
|
>
|
||||||
<SearchResults
|
<SearchResults
|
||||||
// When the query changes, replace the SearchResults component with a
|
// When the query changes, replace the SearchResults component with a
|
||||||
// new instance, to reset `itemIdsToReconsider`. That way, if you find
|
// new instance. This resets both `currentPageNumber`, to take us back
|
||||||
// an item you like in one search, then immediately do a second search
|
// to page 1; and also `itemIdsToReconsider`. That way, if you find an
|
||||||
// and try a conflicting item, we'll restore the item you liked from
|
// item you like in one search, then immediately do a second search and
|
||||||
// your first search!
|
// try a conflicting item, we'll restore the item you liked from your
|
||||||
//
|
// first search!
|
||||||
// NOTE: I wonder how this affects things like state. This component
|
|
||||||
// also tries to gracefully handle changes in the query, but tbh
|
|
||||||
// I wonder whether that's still necessary...
|
|
||||||
key={serializeQuery(query)}
|
key={serializeQuery(query)}
|
||||||
query={query}
|
query={query}
|
||||||
outfitState={outfitState}
|
outfitState={outfitState}
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
scrollContainerRef={scrollContainerRef}
|
|
||||||
firstSearchResultRef={firstSearchResultRef}
|
firstSearchResultRef={firstSearchResultRef}
|
||||||
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
|
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
|
||||||
/>
|
/>
|
||||||
|
@ -82,15 +81,15 @@ function SearchResults({
|
||||||
query,
|
query,
|
||||||
outfitState,
|
outfitState,
|
||||||
dispatchToOutfit,
|
dispatchToOutfit,
|
||||||
scrollContainerRef,
|
|
||||||
firstSearchResultRef,
|
firstSearchResultRef,
|
||||||
onMoveFocusUpToQuery,
|
onMoveFocusUpToQuery,
|
||||||
}) {
|
}) {
|
||||||
const { loading, loadingMore, error, items, fetchMore } = useSearchResults(
|
const [currentPageNumber, setCurrentPageNumber] = React.useState(1);
|
||||||
|
const { loading, error, items, numTotalPages } = useSearchResults(
|
||||||
query,
|
query,
|
||||||
outfitState
|
outfitState,
|
||||||
|
currentPageNumber
|
||||||
);
|
);
|
||||||
useScrollTracker(scrollContainerRef, 300, fetchMore);
|
|
||||||
|
|
||||||
// This will save the `wornItemIds` when the SearchResults first mounts, and
|
// This will save the `wornItemIds` when the SearchResults first mounts, and
|
||||||
// keep it saved even after the outfit changes. We use this to try to restore
|
// keep it saved even after the outfit changes. We use this to try to restore
|
||||||
|
@ -123,14 +122,10 @@ function SearchResults({
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const searchPanelBackground = useColorModeValue("white", "gray.900");
|
||||||
|
|
||||||
// If the results aren't ready, we have some special case UI!
|
// If the results aren't ready, we have some special case UI!
|
||||||
if (loading) {
|
if (error) {
|
||||||
return (
|
|
||||||
<Delay ms={500}>
|
|
||||||
<ItemListSkeleton count={8} />
|
|
||||||
</Delay>
|
|
||||||
);
|
|
||||||
} else if (error) {
|
|
||||||
return (
|
return (
|
||||||
<Text>
|
<Text>
|
||||||
We hit an error trying to load your search results{" "}
|
We hit an error trying to load your search results{" "}
|
||||||
|
@ -140,24 +135,30 @@ function SearchResults({
|
||||||
Try again?
|
Try again?
|
||||||
</Text>
|
</Text>
|
||||||
);
|
);
|
||||||
} else if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<Text>
|
|
||||||
We couldn't find any matching items{" "}
|
|
||||||
<span role="img" aria-label="(thinking emoji)">
|
|
||||||
🤔
|
|
||||||
</span>{" "}
|
|
||||||
Try again?
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Finally, render the item list, with checkboxes and Item components!
|
// Finally, render the item list, with checkboxes and Item components!
|
||||||
// We also render some extra skeleton items at the bottom during infinite
|
// We also render some extra skeleton items at the bottom during infinite
|
||||||
// scroll loading.
|
// scroll loading.
|
||||||
return (
|
return (
|
||||||
<>
|
<Box>
|
||||||
<ItemListContainer>
|
<Box
|
||||||
|
position="sticky"
|
||||||
|
top="0"
|
||||||
|
background={searchPanelBackground}
|
||||||
|
zIndex="2"
|
||||||
|
paddingX="5"
|
||||||
|
paddingBottom="2"
|
||||||
|
paddingTop="1"
|
||||||
|
>
|
||||||
|
<PaginationToolbar
|
||||||
|
numTotalPages={numTotalPages}
|
||||||
|
currentPageNumber={currentPageNumber}
|
||||||
|
goToPageNumber={setCurrentPageNumber}
|
||||||
|
buildPageUrl={() => null}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<ItemListContainer paddingX="4" paddingBottom="2">
|
||||||
{items.map((item, index) => (
|
{items.map((item, index) => (
|
||||||
<SearchResultItem
|
<SearchResultItem
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
@ -172,8 +173,23 @@ function SearchResults({
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</ItemListContainer>
|
</ItemListContainer>
|
||||||
{loadingMore && <ItemListSkeleton count={8} />}
|
{loading && (
|
||||||
</>
|
<ItemListSkeleton
|
||||||
|
count={SEARCH_PER_PAGE}
|
||||||
|
paddingX="4"
|
||||||
|
paddingBottom="2"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{!loading && items.length === 0 && (
|
||||||
|
<Text paddingX="4">
|
||||||
|
We couldn't find any matching items{" "}
|
||||||
|
<span role="img" aria-label="(thinking emoji)">
|
||||||
|
🤔
|
||||||
|
</span>{" "}
|
||||||
|
Try again?
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -245,9 +261,13 @@ function SearchResultItem({
|
||||||
* useSearchResults manages the actual querying and state management of search!
|
* useSearchResults manages the actual querying and state management of search!
|
||||||
* It's hefty, infinite-scroll pagination is a bit of a thing!
|
* It's hefty, infinite-scroll pagination is a bit of a thing!
|
||||||
*/
|
*/
|
||||||
function useSearchResults(query, outfitState) {
|
function useSearchResults(
|
||||||
|
query,
|
||||||
|
outfitState,
|
||||||
|
currentPageNumber,
|
||||||
|
{ skip = false } = {}
|
||||||
|
) {
|
||||||
const { speciesId, colorId } = outfitState;
|
const { speciesId, colorId } = outfitState;
|
||||||
const [isEndOfResults, setIsEndOfResults] = React.useState(false);
|
|
||||||
|
|
||||||
// We debounce the search query, so that we don't resend a new query whenever
|
// We debounce the search query, so that we don't resend a new query whenever
|
||||||
// the user types anything.
|
// the user types anything.
|
||||||
|
@ -256,12 +276,6 @@ function useSearchResults(query, outfitState) {
|
||||||
initialValue: emptySearchQuery,
|
initialValue: emptySearchQuery,
|
||||||
});
|
});
|
||||||
|
|
||||||
// When the query changes, we should update our impression of whether we've
|
|
||||||
// reached the end!
|
|
||||||
React.useEffect(() => {
|
|
||||||
setIsEndOfResults(false);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
// NOTE: This query should always load ~instantly, from the client cache.
|
// NOTE: This query should always load ~instantly, from the client cache.
|
||||||
const { data: zoneData } = useQuery(gql`
|
const { data: zoneData } = useQuery(gql`
|
||||||
query SearchPanelZones {
|
query SearchPanelZones {
|
||||||
|
@ -277,13 +291,11 @@ function useSearchResults(query, outfitState) {
|
||||||
: [];
|
: [];
|
||||||
const filterToZoneIds = filterToZones.map((z) => z.id);
|
const filterToZoneIds = filterToZones.map((z) => z.id);
|
||||||
|
|
||||||
|
const currentPageIndex = currentPageNumber - 1;
|
||||||
|
const offset = currentPageIndex * SEARCH_PER_PAGE;
|
||||||
|
|
||||||
// Here's the actual GQL query! At the bottom we have more config than usual!
|
// Here's the actual GQL query! At the bottom we have more config than usual!
|
||||||
const {
|
const { loading: loadingGQL, error, data } = useQuery(
|
||||||
loading: loadingGQL,
|
|
||||||
error,
|
|
||||||
data,
|
|
||||||
fetchMore: fetchMoreGQL,
|
|
||||||
} = useQuery(
|
|
||||||
gql`
|
gql`
|
||||||
query SearchPanel(
|
query SearchPanel(
|
||||||
$query: String!
|
$query: String!
|
||||||
|
@ -294,6 +306,7 @@ function useSearchResults(query, outfitState) {
|
||||||
$speciesId: ID!
|
$speciesId: ID!
|
||||||
$colorId: ID!
|
$colorId: ID!
|
||||||
$offset: Int!
|
$offset: Int!
|
||||||
|
$perPage: Int!
|
||||||
) {
|
) {
|
||||||
itemSearch: itemSearchV2(
|
itemSearch: itemSearchV2(
|
||||||
query: $query
|
query: $query
|
||||||
|
@ -302,11 +315,8 @@ function useSearchResults(query, outfitState) {
|
||||||
currentUserOwnsOrWants: $currentUserOwnsOrWants
|
currentUserOwnsOrWants: $currentUserOwnsOrWants
|
||||||
zoneIds: $zoneIds
|
zoneIds: $zoneIds
|
||||||
) {
|
) {
|
||||||
query
|
numTotalItems
|
||||||
zones {
|
items(offset: $offset, limit: $perPage) {
|
||||||
id
|
|
||||||
}
|
|
||||||
items(offset: $offset, limit: 50) {
|
|
||||||
# TODO: De-dupe this from useOutfitState?
|
# TODO: De-dupe this from useOutfitState?
|
||||||
id
|
id
|
||||||
name
|
name
|
||||||
|
@ -346,139 +356,32 @@ function useSearchResults(query, outfitState) {
|
||||||
itemKind: debouncedQuery.filterToItemKind,
|
itemKind: debouncedQuery.filterToItemKind,
|
||||||
currentUserOwnsOrWants: debouncedQuery.filterToCurrentUserOwnsOrWants,
|
currentUserOwnsOrWants: debouncedQuery.filterToCurrentUserOwnsOrWants,
|
||||||
zoneIds: filterToZoneIds,
|
zoneIds: filterToZoneIds,
|
||||||
offset: 0,
|
|
||||||
speciesId,
|
speciesId,
|
||||||
colorId,
|
colorId,
|
||||||
|
offset,
|
||||||
|
perPage: SEARCH_PER_PAGE,
|
||||||
},
|
},
|
||||||
context: { sendAuth: true },
|
context: { sendAuth: true },
|
||||||
skip:
|
skip:
|
||||||
!debouncedQuery.value &&
|
skip ||
|
||||||
!debouncedQuery.filterToItemKind &&
|
(!debouncedQuery.value &&
|
||||||
!debouncedQuery.filterToZoneLabel &&
|
!debouncedQuery.filterToItemKind &&
|
||||||
!debouncedQuery.filterToCurrentUserOwnsOrWants,
|
!debouncedQuery.filterToZoneLabel &&
|
||||||
notifyOnNetworkStatusChange: true,
|
!debouncedQuery.filterToCurrentUserOwnsOrWants),
|
||||||
onCompleted: (d) => {
|
|
||||||
// This is called each time the query completes, including on
|
|
||||||
// `fetchMore`, with the extended results. But, on the first time, this
|
|
||||||
// logic can tell us whether we're at the end of the list, by counting
|
|
||||||
// whether there was <30. We also have to check in `fetchMore`!
|
|
||||||
const items = d && d.itemSearch && d.itemSearch.items;
|
|
||||||
if (items && items.length < 30) {
|
|
||||||
setIsEndOfResults(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (e) => {
|
onError: (e) => {
|
||||||
console.error("Error loading search results", e);
|
console.error("Error loading search results", e);
|
||||||
},
|
},
|
||||||
|
// Return `numTotalItems` from the GQL cache while waiting for next page!
|
||||||
|
returnPartialData: true,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
// Smooth over the data a bit, so that we can use key fields with confidence!
|
const loading = debouncedQuery !== query || loadingGQL;
|
||||||
const result = data?.itemSearch;
|
const items = data?.itemSearch?.items ?? [];
|
||||||
const resultValue = result?.query;
|
const numTotalItems = data?.itemSearch?.numTotalItems ?? null;
|
||||||
const zoneStr = [...filterToZoneIds].sort().join(",");
|
const numTotalPages = Math.ceil(numTotalItems / SEARCH_PER_PAGE);
|
||||||
const resultZoneStr = (result?.zones || [])
|
|
||||||
.map((z) => z.id)
|
|
||||||
.sort()
|
|
||||||
.join(",");
|
|
||||||
const queriesMatch = resultValue === query.value && resultZoneStr === zoneStr;
|
|
||||||
const items = result?.items || [];
|
|
||||||
|
|
||||||
// Okay, what kind of loading state is this?
|
return { loading, error, items, numTotalPages };
|
||||||
let loading;
|
|
||||||
let loadingMore;
|
|
||||||
if (loadingGQL && items.length > 0 && queriesMatch) {
|
|
||||||
// If we already have items for this query, but we're also loading GQL,
|
|
||||||
// then we're `loadingMore`.
|
|
||||||
loading = false;
|
|
||||||
loadingMore = true;
|
|
||||||
} else if (loadingGQL || query !== debouncedQuery) {
|
|
||||||
// Otherwise, if we're loading GQL or the user has changed the query, we're
|
|
||||||
// just `loading`.
|
|
||||||
loading = true;
|
|
||||||
loadingMore = false;
|
|
||||||
} else {
|
|
||||||
// Otherwise, we're not loading at all!
|
|
||||||
loading = false;
|
|
||||||
loadingMore = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// When SearchResults calls this, we'll resend the query, with the `offset`
|
|
||||||
// increased. We'll append the results to the original query!
|
|
||||||
const fetchMore = React.useCallback(() => {
|
|
||||||
if (!loadingGQL && !error && !isEndOfResults) {
|
|
||||||
fetchMoreGQL({
|
|
||||||
variables: {
|
|
||||||
offset: items.length,
|
|
||||||
},
|
|
||||||
updateQuery: (prev, { fetchMoreResult }) => {
|
|
||||||
// Note: This is a bit awkward because, if the results count ends on
|
|
||||||
// a multiple of 30, the user will see a flash of loading before
|
|
||||||
// getting told it's actually the end. Ah well :/
|
|
||||||
//
|
|
||||||
// We could maybe make this more rigorous later with
|
|
||||||
// react-virtualized to have a better scrollbar anyway, and then
|
|
||||||
// we'd need to return the total result count... a bit annoying to
|
|
||||||
// potentially double the query runtime? We'd need to see how slow it
|
|
||||||
// actually makes things.
|
|
||||||
if (fetchMoreResult.itemSearch.items.length < 30) {
|
|
||||||
setIsEndOfResults(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...prev,
|
|
||||||
itemSearch: {
|
|
||||||
...(prev?.itemSearch || {}),
|
|
||||||
items: [
|
|
||||||
...(prev?.itemSearch?.items || []),
|
|
||||||
...(fetchMoreResult?.itemSearch?.items || []),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}).catch((e) => {
|
|
||||||
console.error("Error loading more search results pages", e);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [loadingGQL, error, isEndOfResults, fetchMoreGQL, items.length]);
|
|
||||||
|
|
||||||
return { loading, loadingMore, error, items, fetchMore };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useScrollTracker watches for the given scroll container to scroll near the
|
|
||||||
* bottom, then fires a callback. We use this to fetch more search results!
|
|
||||||
*/
|
|
||||||
function useScrollTracker(scrollContainerRef, threshold, onScrolledToBottom) {
|
|
||||||
const onScroll = React.useCallback(
|
|
||||||
(e) => {
|
|
||||||
const topEdgeScrollPosition = e.target.scrollTop;
|
|
||||||
const bottomEdgeScrollPosition =
|
|
||||||
topEdgeScrollPosition + e.target.clientHeight;
|
|
||||||
const remainingScrollDistance =
|
|
||||||
e.target.scrollHeight - bottomEdgeScrollPosition;
|
|
||||||
if (remainingScrollDistance < threshold) {
|
|
||||||
onScrolledToBottom();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[onScrolledToBottom, threshold]
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useLayoutEffect(() => {
|
|
||||||
const scrollContainer = scrollContainerRef.current;
|
|
||||||
|
|
||||||
if (!scrollContainer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
scrollContainer.addEventListener("scroll", onScroll);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (scrollContainer) {
|
|
||||||
scrollContainer.removeEventListener("scroll", onScroll);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [onScroll, scrollContainerRef]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -22,6 +22,11 @@ function PaginationToolbar({
|
||||||
<Flex align="center" justify="space-between" {...props}>
|
<Flex align="center" justify="space-between" {...props}>
|
||||||
<LinkOrButton
|
<LinkOrButton
|
||||||
href={prevPageUrl}
|
href={prevPageUrl}
|
||||||
|
onClick={
|
||||||
|
prevPageUrl == null
|
||||||
|
? () => goToPageNumber(currentPageNumber - 1)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
_disabled={{
|
_disabled={{
|
||||||
cursor: isLoading ? "wait" : "not-allowed",
|
cursor: isLoading ? "wait" : "not-allowed",
|
||||||
opacity: 0.4,
|
opacity: 0.4,
|
||||||
|
@ -30,7 +35,7 @@ function PaginationToolbar({
|
||||||
>
|
>
|
||||||
← Prev
|
← Prev
|
||||||
</LinkOrButton>
|
</LinkOrButton>
|
||||||
{numTotalPages && (
|
{numTotalPages > 0 && (
|
||||||
<Flex align="center">
|
<Flex align="center">
|
||||||
<Box flex="0 0 auto">Page</Box>
|
<Box flex="0 0 auto">Page</Box>
|
||||||
<Box width="1" />
|
<Box width="1" />
|
||||||
|
@ -46,6 +51,11 @@ function PaginationToolbar({
|
||||||
)}
|
)}
|
||||||
<LinkOrButton
|
<LinkOrButton
|
||||||
href={nextPageUrl}
|
href={nextPageUrl}
|
||||||
|
onClick={
|
||||||
|
nextPageUrl == null
|
||||||
|
? () => goToPageNumber(currentPageNumber + 1)
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
_disabled={{
|
_disabled={{
|
||||||
cursor: isLoading ? "wait" : "not-allowed",
|
cursor: isLoading ? "wait" : "not-allowed",
|
||||||
opacity: 0.4,
|
opacity: 0.4,
|
||||||
|
|
Loading…
Reference in a new issue