import React from "react"; import gql from "graphql-tag"; import { Box, Text, VisuallyHidden } from "@chakra-ui/core"; import { useQuery } from "@apollo/client"; import { Delay, Heading1, useDebounce } from "../util"; import Item, { ItemListContainer, ItemListSkeleton } from "./Item"; import { itemAppearanceFragment } from "../components/useOutfitAppearance"; /** * 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({ query, outfitState, dispatchToOutfit, scrollContainerRef, searchQueryRef, firstSearchResultRef, }) { // Whenever the search query changes, scroll back up to the top! React.useEffect(() => { if (scrollContainerRef.current) { scrollContainerRef.current.scrollTop = 0; } }, [query, scrollContainerRef]); // Sometimes we want to give focus back to the search field! const onMoveFocusUpToQuery = (e) => { if (searchQueryRef.current) { searchQueryRef.current.focus(); e.preventDefault(); } }; return ( <Box onKeyDown={(e) => { // This will catch any Escape presses when the user's focus is inside // the SearchPanel. if (e.key === "Escape") { onMoveFocusUpToQuery(e); } }} > <Heading1 mb="4">Searching for "{query}"</Heading1> <SearchResults query={query} outfitState={outfitState} dispatchToOutfit={dispatchToOutfit} scrollContainerRef={scrollContainerRef} firstSearchResultRef={firstSearchResultRef} onMoveFocusUpToQuery={onMoveFocusUpToQuery} /> </Box> ); } /** * 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({ query, outfitState, dispatchToOutfit, scrollContainerRef, firstSearchResultRef, onMoveFocusUpToQuery, }) { const { loading, loadingMore, error, items, fetchMore } = useSearchResults( query, outfitState ); useScrollTracker(scrollContainerRef, 300, fetchMore); // 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> 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> 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} isWorn={outfitState.wornItemIds.includes(item.id)} isInOutfit={outfitState.allItemIds.includes(item.id)} 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); // 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(() => { setIsEndOfResults(false); }, [query]); // Here's the actual GQL query! At the bottom we have more config than usual! const { loading: loadingGQL, error, data, fetchMore: fetchMoreGQL, } = useQuery( gql` query SearchPanel( $query: String! $speciesId: ID! $colorId: ID! $offset: Int! ) { itemSearchToFit( query: $query speciesId: $speciesId colorId: $colorId offset: $offset limit: 50 ) { query items { # TODO: De-dupe this from useOutfitState? id name thumbnailUrl appearanceOn(speciesId: $speciesId, colorId: $colorId) { # This enables us to quickly show the item when the user clicks it! ...ItemAppearanceForOutfitPreview # This is used to group items by zone, and to detect conflicts when # wearing a new item. layers { zone { id label } } } } } } ${itemAppearanceFragment} `, { variables: { query: debouncedQuery, speciesId, colorId, offset: 0 }, skip: debouncedQuery === null, notifyOnNetworkStatusChange: true, 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; if (items && items.length < 30) { setIsEndOfResults(true); } }, } ); // Smooth over the data a bit, so that we can use key fields with confidence! const result = data && data.itemSearchToFit; const resultQuery = result && result.query; const items = (result && result.items) || []; // Okay, what kind of loading state is this? let loading; 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: { offset: items.length, }, updateQuery: (prev, { fetchMoreResult }) => { if (!fetchMoreResult || fetchMoreResult.query !== prev.query) { return prev; } // 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.itemSearchToFit.items.length < 30) { setIsEndOfResults(true); } return { ...prev, itemSearchToFit: { ...prev.itemSearchToFit, items: [ ...prev.itemSearchToFit.items, ...fetchMoreResult.itemSearchToFit.items, ], }, }; }, }); } }, [loadingGQL, 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]); } export default SearchPanel;