SearchPanel docs & cleanup
This commit is contained in:
parent
22d75d90c1
commit
864e275769
1 changed files with 178 additions and 111 deletions
|
@ -7,6 +7,13 @@ import { Delay, Heading1, useDebounce } from "./util";
|
||||||
import { Item, ItemListContainer, ItemListSkeleton } from "./Item";
|
import { Item, ItemListContainer, ItemListSkeleton } from "./Item";
|
||||||
import { itemAppearanceFragment } from "./OutfitPreview";
|
import { itemAppearanceFragment } from "./OutfitPreview";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SearchPanel shows item search results to the user, so they can preview them
|
||||||
|
* and add them to their outfit!
|
||||||
|
*
|
||||||
|
* It's tightly coordinated with SearchToolbar, using refs to control special
|
||||||
|
* keyboard and focus interactions.
|
||||||
|
*/
|
||||||
function SearchPanel({
|
function SearchPanel({
|
||||||
query,
|
query,
|
||||||
outfitState,
|
outfitState,
|
||||||
|
@ -22,6 +29,7 @@ function SearchPanel({
|
||||||
}
|
}
|
||||||
}, [query, scrollContainerRef]);
|
}, [query, scrollContainerRef]);
|
||||||
|
|
||||||
|
// Sometimes we want to give focus back to the search field!
|
||||||
const onMoveFocusUpToQuery = (e) => {
|
const onMoveFocusUpToQuery = (e) => {
|
||||||
if (searchQueryRef.current) {
|
if (searchQueryRef.current) {
|
||||||
searchQueryRef.current.focus();
|
searchQueryRef.current.focus();
|
||||||
|
@ -33,6 +41,8 @@ function SearchPanel({
|
||||||
<Box
|
<Box
|
||||||
color="green.800"
|
color="green.800"
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
|
// This will catch any Escape presses when the user's focus is inside
|
||||||
|
// the SearchPanel.
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
onMoveFocusUpToQuery(e);
|
onMoveFocusUpToQuery(e);
|
||||||
}
|
}
|
||||||
|
@ -51,6 +61,14 @@ function SearchPanel({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SearchResults loads the search results from the user's query, renders them,
|
||||||
|
* and tracks the scroll container for infinite scrolling.
|
||||||
|
*
|
||||||
|
* For each item, we render a <label> with a visually-hidden checkbox and the
|
||||||
|
* Item component (which will visually reflect the radio's state). This makes
|
||||||
|
* the list screen-reader- and keyboard-accessible!
|
||||||
|
*/
|
||||||
function SearchResults({
|
function SearchResults({
|
||||||
query,
|
query,
|
||||||
outfitState,
|
outfitState,
|
||||||
|
@ -59,16 +77,138 @@ function SearchResults({
|
||||||
firstSearchResultRef,
|
firstSearchResultRef,
|
||||||
onMoveFocusUpToQuery,
|
onMoveFocusUpToQuery,
|
||||||
}) {
|
}) {
|
||||||
const { speciesId, colorId } = outfitState;
|
const { loading, loadingMore, error, items, fetchMore } = useSearchResults(
|
||||||
|
query,
|
||||||
|
outfitState
|
||||||
|
);
|
||||||
|
useScrollTracker(scrollContainerRef, 300, fetchMore);
|
||||||
|
|
||||||
const debouncedQuery = useDebounce(query, 300, { waitForFirstPause: true });
|
// When the checkbox changes, we should wear/unwear the item!
|
||||||
|
const onChange = (e) => {
|
||||||
|
const itemId = e.target.value;
|
||||||
|
const willBeWorn = e.target.checked;
|
||||||
|
if (willBeWorn) {
|
||||||
|
dispatchToOutfit({ type: "wearItem", itemId });
|
||||||
|
} else {
|
||||||
|
dispatchToOutfit({ type: "unwearItem", itemId });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// You can use UpArrow/DownArrow to navigate between items, and even back up
|
||||||
|
// to the search field!
|
||||||
|
const goToPrevItem = (e) => {
|
||||||
|
const prevLabel = e.target.closest("label").previousSibling;
|
||||||
|
if (prevLabel) {
|
||||||
|
prevLabel.querySelector("input[type=checkbox]").focus();
|
||||||
|
prevLabel.scrollIntoView({ block: "center" });
|
||||||
|
e.preventDefault();
|
||||||
|
} else {
|
||||||
|
// If we're at the top of the list, move back up to the search box!
|
||||||
|
onMoveFocusUpToQuery(e);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const goToNextItem = (e) => {
|
||||||
|
const nextLabel = e.target.closest("label").nextSibling;
|
||||||
|
if (nextLabel) {
|
||||||
|
nextLabel.querySelector("input[type=checkbox]").focus();
|
||||||
|
nextLabel.scrollIntoView({ block: "center" });
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// If the results aren't ready, we have some special case UI!
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Delay ms={500}>
|
||||||
|
<ItemListSkeleton count={8} />
|
||||||
|
</Delay>
|
||||||
|
);
|
||||||
|
} else if (error) {
|
||||||
|
return (
|
||||||
|
<Text color="green.500">
|
||||||
|
We hit an error trying to load your search results{" "}
|
||||||
|
<span role="img" aria-label="(sweat emoji)">
|
||||||
|
😓
|
||||||
|
</span>{" "}
|
||||||
|
Try again?
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
} else if (items.length === 0) {
|
||||||
|
return (
|
||||||
|
<Text color="green.500">
|
||||||
|
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!
|
||||||
|
// We also render some extra skeleton items at the bottom during infinite
|
||||||
|
// scroll loading.
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<ItemListContainer>
|
||||||
|
{items.map((item, index) => (
|
||||||
|
<label key={item.id}>
|
||||||
|
<VisuallyHidden
|
||||||
|
as="input"
|
||||||
|
type="checkbox"
|
||||||
|
aria-label={`Wear "${item.name}"`}
|
||||||
|
value={item.id}
|
||||||
|
checked={outfitState.wornItemIds.includes(item.id)}
|
||||||
|
ref={index === 0 ? firstSearchResultRef : null}
|
||||||
|
onChange={onChange}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.target.click();
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
goToPrevItem(e);
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
goToNextItem(e);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Item
|
||||||
|
item={item}
|
||||||
|
outfitState={outfitState}
|
||||||
|
dispatchToOutfit={dispatchToOutfit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</ItemListContainer>
|
||||||
|
{loadingMore && <ItemListSkeleton count={8} />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useSearchResults manages the actual querying and state management of search!
|
||||||
|
* It's hefty, infinite-scroll pagination is a bit of a thing!
|
||||||
|
*/
|
||||||
|
function useSearchResults(query, outfitState) {
|
||||||
|
const { speciesId, colorId } = outfitState;
|
||||||
const [isEndOfResults, setIsEndOfResults] = React.useState(false);
|
const [isEndOfResults, setIsEndOfResults] = React.useState(false);
|
||||||
|
|
||||||
|
// We debounce the search query, so that we don't resend a new query whenever
|
||||||
|
// the user types anything.
|
||||||
|
const debouncedQuery = useDebounce(query, 300, { waitForFirstPause: true });
|
||||||
|
|
||||||
|
// When the query changes, we should update our impression of whether we've
|
||||||
|
// reached the end!
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
setIsEndOfResults(false);
|
setIsEndOfResults(false);
|
||||||
}, [query]);
|
}, [query]);
|
||||||
|
|
||||||
const { loading, error, data, fetchMore } = useQuery(
|
// Here's the actual GQL query! At the bottom we have more config than usual!
|
||||||
|
const {
|
||||||
|
loading: loadingGQL,
|
||||||
|
error,
|
||||||
|
data,
|
||||||
|
fetchMore: fetchMoreGQL,
|
||||||
|
} = useQuery(
|
||||||
gql`
|
gql`
|
||||||
query($query: String!, $speciesId: ID!, $colorId: ID!, $offset: Int!) {
|
query($query: String!, $speciesId: ID!, $colorId: ID!, $offset: Int!) {
|
||||||
itemSearchToFit(
|
itemSearchToFit(
|
||||||
|
@ -108,6 +248,10 @@ function SearchResults({
|
||||||
skip: debouncedQuery === null,
|
skip: debouncedQuery === null,
|
||||||
notifyOnNetworkStatusChange: true,
|
notifyOnNetworkStatusChange: true,
|
||||||
onCompleted: (d) => {
|
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.itemSearchToFit && d.itemSearchToFit.items;
|
const items = d && d.itemSearchToFit && d.itemSearchToFit.items;
|
||||||
if (items && items.length < 30) {
|
if (items && items.length < 30) {
|
||||||
setIsEndOfResults(true);
|
setIsEndOfResults(true);
|
||||||
|
@ -116,13 +260,34 @@ function SearchResults({
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Smooth over the data a bit, so that we can use key fields with confidence!
|
||||||
const result = data && data.itemSearchToFit;
|
const result = data && data.itemSearchToFit;
|
||||||
const resultQuery = result && result.query;
|
const resultQuery = result && result.query;
|
||||||
const items = (result && result.items) || [];
|
const items = (result && result.items) || [];
|
||||||
|
|
||||||
const onScrolledToBottom = React.useCallback(() => {
|
// Okay, what kind of loading state is this?
|
||||||
if (!loading && !isEndOfResults) {
|
let loading;
|
||||||
fetchMore({
|
let loadingMore;
|
||||||
|
if ((loadingGQL && items.length === 0) || resultQuery !== query) {
|
||||||
|
// If it's our first run, or the first run _since the query changed_, we're
|
||||||
|
// `loading`.
|
||||||
|
loading = true;
|
||||||
|
loadingMore = false;
|
||||||
|
} else if (loadingGQL) {
|
||||||
|
// Or, if we're loading GQL but it's not our first run, we're `loadingMore`.
|
||||||
|
loading = false;
|
||||||
|
loadingMore = true;
|
||||||
|
} 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 && !isEndOfResults) {
|
||||||
|
fetchMoreGQL({
|
||||||
variables: {
|
variables: {
|
||||||
offset: items.length,
|
offset: items.length,
|
||||||
},
|
},
|
||||||
|
@ -157,114 +322,16 @@ function SearchResults({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [loading, isEndOfResults, fetchMore, items.length]);
|
}, [loadingGQL, isEndOfResults, fetchMoreGQL, items.length]);
|
||||||
|
|
||||||
useScrollTracker({ threshold: 300, scrollContainerRef, onScrolledToBottom });
|
return { loading, loadingMore, error, items, fetchMore };
|
||||||
|
|
||||||
if (resultQuery !== query || (loading && items.length === 0)) {
|
|
||||||
return (
|
|
||||||
<Delay ms={500}>
|
|
||||||
<ItemListSkeleton count={8} />
|
|
||||||
</Delay>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return (
|
|
||||||
<Text color="green.500">
|
|
||||||
We hit an error trying to load your search results{" "}
|
|
||||||
<span role="img" aria-label="(sweat emoji)">
|
|
||||||
😓
|
|
||||||
</span>{" "}
|
|
||||||
Try again?
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return (
|
|
||||||
<Text color="green.500">
|
|
||||||
We couldn't find any matching items{" "}
|
|
||||||
<span role="img" aria-label="(thinking emoji)">
|
|
||||||
🤔
|
|
||||||
</span>{" "}
|
|
||||||
Try again?
|
|
||||||
</Text>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const onChange = (e) => {
|
|
||||||
const itemId = e.target.value;
|
|
||||||
const willBeWorn = e.target.checked;
|
|
||||||
if (willBeWorn) {
|
|
||||||
dispatchToOutfit({ type: "wearItem", itemId });
|
|
||||||
} else {
|
|
||||||
dispatchToOutfit({ type: "unwearItem", itemId });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToPrevItem = (e) => {
|
|
||||||
const prevLabel = e.target.closest("label").previousSibling;
|
|
||||||
if (prevLabel) {
|
|
||||||
prevLabel.querySelector("input[type=checkbox]").focus();
|
|
||||||
prevLabel.scrollIntoView({ block: "center" });
|
|
||||||
e.preventDefault();
|
|
||||||
} else {
|
|
||||||
// If we're at the top of the list, move back up to the search box!
|
|
||||||
onMoveFocusUpToQuery(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToNextItem = (e) => {
|
|
||||||
const nextLabel = e.target.closest("label").nextSibling;
|
|
||||||
if (nextLabel) {
|
|
||||||
nextLabel.querySelector("input[type=checkbox]").focus();
|
|
||||||
nextLabel.scrollIntoView({ block: "center" });
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<ItemListContainer>
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<label key={item.id}>
|
|
||||||
<VisuallyHidden
|
|
||||||
as="input"
|
|
||||||
type="checkbox"
|
|
||||||
aria-label={`Wear "${item.name}"`}
|
|
||||||
value={item.id}
|
|
||||||
checked={outfitState.wornItemIds.includes(item.id)}
|
|
||||||
ref={index === 0 ? firstSearchResultRef : null}
|
|
||||||
onChange={onChange}
|
|
||||||
onKeyDown={(e) => {
|
|
||||||
if (e.key === "Enter") {
|
|
||||||
e.target.click();
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
goToPrevItem(e);
|
|
||||||
} else if (e.key === "ArrowDown") {
|
|
||||||
goToNextItem(e);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<Item
|
|
||||||
item={item}
|
|
||||||
outfitState={outfitState}
|
|
||||||
dispatchToOutfit={dispatchToOutfit}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
))}
|
|
||||||
</ItemListContainer>
|
|
||||||
{items && loading && <ItemListSkeleton count={8} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function useScrollTracker({
|
/**
|
||||||
threshold,
|
* useScrollTracker watches for the given scroll container to scroll near the
|
||||||
scrollContainerRef,
|
* bottom, then fires a callback. We use this to fetch more search results!
|
||||||
onScrolledToBottom,
|
*/
|
||||||
}) {
|
function useScrollTracker(scrollContainerRef, threshold, onScrolledToBottom) {
|
||||||
const onScroll = React.useCallback(
|
const onScroll = React.useCallback(
|
||||||
(e) => {
|
(e) => {
|
||||||
const topEdgeScrollPosition = e.target.scrollTop;
|
const topEdgeScrollPosition = e.target.scrollTop;
|
||||||
|
|
Loading…
Reference in a new issue