create ItemsAndSearchPanel, simplify search refs

This commit is contained in:
Matt Dunn-Rankin 2020-04-26 00:37:58 -07:00
parent 5371cbbd4a
commit 776bc33329
4 changed files with 211 additions and 160 deletions

View file

@ -2,5 +2,7 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"editor.tabSize": 2, "editor.tabSize": 2,
"jest.pathToJest": "yarn test", "jest.pathToJest": "yarn test",
"jest.autoEnable": false "jest.autoEnable": false,
"javascript.suggest.completeJSDocs": false,
"typescript.suggest.completeJSDocs": false
} }

View file

@ -0,0 +1,146 @@
import React from "react";
import {
Box,
Flex,
Icon,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
} from "@chakra-ui/core";
import ItemsPanel from "./ItemsPanel";
import SearchPanel from "./SearchPanel";
/**
* ItemsAndSearchPanels manages the shared layout and state for:
* - ItemsPanel, which shows the items in the outfit now, and
* - SearchPanel, which helps you find new items to add.
*
* These panels don't share a _lot_ of concerns; they're mainly intertwined by
* the fact that they share the SearchToolbar at the top!
*
* We try to keep the search concerns in the search components, by avoiding
* letting any actual _logic_ live at the root here; and instead just
* performing some wiring to help them interact with each other via simple
* state and refs.
*/
function ItemsAndSearchPanels({ loading, outfitState, dispatchToOutfit }) {
const [searchQuery, setSearchQuery] = React.useState("");
const scrollContainerRef = React.useRef();
const searchQueryRef = React.useRef();
const firstSearchResultRef = React.useRef();
return (
<Flex direction="column" height="100%">
<Box px="5" py="3" boxShadow="sm">
<SearchToolbar
query={searchQuery}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
onChange={setSearchQuery}
/>
</Box>
{searchQuery ? (
<Box
key="search-panel"
gridArea="items"
position="relative"
overflow="auto"
ref={scrollContainerRef}
>
<Box px="4" py="5">
<SearchPanel
query={searchQuery}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
scrollContainerRef={scrollContainerRef}
searchQueryRef={searchQueryRef}
firstSearchResultRef={firstSearchResultRef}
/>
</Box>
</Box>
) : (
<Box
gridArea="items"
position="relative"
overflow="auto"
key="items-panel"
>
<Box px="4" py="5">
<ItemsPanel
loading={loading}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</Box>
)}
</Flex>
);
}
/**
* SearchToolbar is rendered above both the ItemsPanel and the SearchPanel,
* and contains the search field where the user types their query.
*
* It has some subtle keyboard interaction support, like DownArrow to go to the
* first search result, and Escape to clear the search and go back to the
* ItemsPanel. (The SearchPanel can also send focus back to here, with Escape
* from anywhere, or UpArrow from the first result!)
*/
function SearchToolbar({
query,
searchQueryRef,
firstSearchResultRef,
onChange,
}) {
const onMoveFocusDownToResults = (e) => {
if (firstSearchResultRef.current) {
firstSearchResultRef.current.focus();
e.preventDefault();
}
};
return (
<InputGroup>
<InputLeftElement>
<Icon name="search" color="gray.400" />
</InputLeftElement>
<Input
placeholder="Search for items to add…"
focusBorderColor="green.600"
color="green.800"
value={query}
ref={searchQueryRef}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
onChange("");
e.target.blur();
} else if (e.key === "ArrowDown") {
onMoveFocusDownToResults(e);
}
}}
/>
{query && (
<InputRightElement>
<IconButton
icon="close"
color="gray.400"
variant="ghost"
variantColor="green"
aria-label="Clear search"
onClick={() => onChange("")}
// Big style hacks here!
height="calc(100% - 2px)"
marginRight="2px"
/>
</InputRightElement>
)}
</InputGroup>
);
}
export default ItemsAndSearchPanels;

View file

@ -11,9 +11,24 @@ function SearchPanel({
query, query,
outfitState, outfitState,
dispatchToOutfit, dispatchToOutfit,
scrollContainerRef,
searchQueryRef,
firstSearchResultRef, firstSearchResultRef,
onMoveFocusUpToQuery,
}) { }) {
// Whenever the search query changes, scroll back up to the top!
React.useEffect(() => {
if (scrollContainerRef.current) {
scrollContainerRef.current.scrollTop = 0;
}
}, [query, scrollContainerRef]);
const onMoveFocusUpToQuery = (e) => {
if (searchQueryRef.current) {
searchQueryRef.current.focus();
e.preventDefault();
}
};
return ( return (
<Box <Box
color="green.800" color="green.800"
@ -28,6 +43,7 @@ function SearchPanel({
query={query} query={query}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
scrollContainerRef={scrollContainerRef}
firstSearchResultRef={firstSearchResultRef} firstSearchResultRef={firstSearchResultRef}
onMoveFocusUpToQuery={onMoveFocusUpToQuery} onMoveFocusUpToQuery={onMoveFocusUpToQuery}
/> />
@ -39,6 +55,7 @@ function SearchResults({
query, query,
outfitState, outfitState,
dispatchToOutfit, dispatchToOutfit,
scrollContainerRef,
firstSearchResultRef, firstSearchResultRef,
onMoveFocusUpToQuery, onMoveFocusUpToQuery,
}) { }) {
@ -142,6 +159,8 @@ function SearchResults({
} }
}, [loading, isEndOfResults, fetchMore, items.length]); }, [loading, isEndOfResults, fetchMore, items.length]);
useScrollTracker({ threshold: 300, scrollContainerRef, onScrolledToBottom });
if (resultQuery !== query || (loading && items.length === 0)) { if (resultQuery !== query || (loading && items.length === 0)) {
return ( return (
<Delay ms={500}> <Delay ms={500}>
@ -206,7 +225,7 @@ function SearchResults({
}; };
return ( return (
<ScrollTracker threshold={300} onScrolledToBottom={onScrolledToBottom}> <>
<ItemListContainer> <ItemListContainer>
{items.map((item, index) => ( {items.map((item, index) => (
<label key={item.id}> <label key={item.id}>
@ -237,14 +256,15 @@ function SearchResults({
))} ))}
</ItemListContainer> </ItemListContainer>
{items && loading && <ItemListSkeleton count={8} />} {items && loading && <ItemListSkeleton count={8} />}
</ScrollTracker> </>
); );
} }
function ScrollTracker({ children, threshold, onScrolledToBottom }) { function useScrollTracker({
const containerRef = React.useRef(); threshold,
const scrollParent = React.useRef(); scrollContainerRef,
onScrolledToBottom,
}) {
const onScroll = React.useCallback( const onScroll = React.useCallback(
(e) => { (e) => {
const topEdgeScrollPosition = e.target.scrollTop; const topEdgeScrollPosition = e.target.scrollTop;
@ -260,31 +280,20 @@ function ScrollTracker({ children, threshold, onScrolledToBottom }) {
); );
React.useLayoutEffect(() => { React.useLayoutEffect(() => {
if (!containerRef.current) { const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return; return;
} }
scrollParent.current = getScrollParent(containerRef.current); scrollContainer.addEventListener("scroll", onScroll);
scrollParent.current.addEventListener("scroll", onScroll);
return () => { return () => {
if (scrollParent.current) { if (scrollContainer) {
scrollParent.current.removeEventListener("scroll", onScroll); scrollContainer.removeEventListener("scroll", onScroll);
} }
}; };
}, [onScroll]); }, [onScroll, scrollContainerRef]);
return <div ref={containerRef}>{children}</div>;
}
function getScrollParent(target) {
for (let el = target; el.parentNode; el = el.parentNode) {
if (getComputedStyle(el).overflow === "auto") {
return el;
}
}
throw new Error(`found no scroll parent`);
} }
export default SearchPanel; export default SearchPanel;

View file

@ -1,30 +1,28 @@
import React from "react"; import React from "react";
import { import { Box, Grid, useToast } from "@chakra-ui/core";
Box,
Grid,
Icon,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
useToast,
} from "@chakra-ui/core";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import ItemsPanel from "./ItemsPanel"; import ItemsAndSearchPanels from "./ItemsAndSearchPanels";
import OutfitPreview from "./OutfitPreview"; import OutfitPreview from "./OutfitPreview";
import SearchPanel from "./SearchPanel";
import useOutfitState from "./useOutfitState.js"; import useOutfitState from "./useOutfitState.js";
/**
* WardrobePage is the most fun page on the site - it's where you create
* outfits!
*
* This page has two sections: the OutfitPreview, where we show the outfit as a
* big image; and the ItemsAndSearchPanels, which let you manage which items
* are in the outfit and find new ones.
*
* This component manages shared outfit state, and the fullscreen responsive
* page layout.
*/
function WardrobePage() { function WardrobePage() {
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
const [searchQuery, setSearchQuery] = React.useState("");
const toast = useToast(); const toast = useToast();
const searchContainerRef = React.useRef(); const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
const searchQueryRef = React.useRef();
const firstSearchResultRef = React.useRef();
// TODO: I haven't found a great place for this error UI yet, and this case
// isn't very common, so this lil toast notification seems good enough!
React.useEffect(() => { React.useEffect(() => {
if (error) { if (error) {
console.log(error); console.log(error);
@ -38,12 +36,6 @@ function WardrobePage() {
} }
}, [error, toast]); }, [error, toast]);
React.useEffect(() => {
if (searchContainerRef.current) {
searchContainerRef.current.scrollTop = 0;
}
}, [searchQuery]);
return ( return (
<> <>
<Helmet> <Helmet>
@ -53,18 +45,14 @@ function WardrobePage() {
</Helmet> </Helmet>
<Box position="absolute" top="0" bottom="0" left="0" right="0"> <Box position="absolute" top="0" bottom="0" left="0" right="0">
<Grid <Grid
// Fullscreen, split into a vertical stack on smaller screens
// or a horizontal stack on larger ones!
templateAreas={{ templateAreas={{
base: `"outfit" base: `"preview"
"search" "itemsAndSearch"`,
"items"`, lg: `"preview itemsAndSearch"`,
lg: `"outfit search"
"outfit items"`,
}} }}
templateRows={{ templateRows={{
base: "minmax(100px, 1fr) auto minmax(300px, 1fr)", base: "minmax(100px, 45%) minmax(300px, 55%)",
lg: "auto 1fr", lg: "100%",
}} }}
templateColumns={{ templateColumns={{
base: "100%", base: "100%",
@ -73,117 +61,23 @@ function WardrobePage() {
height="100%" height="100%"
width="100%" width="100%"
> >
<Box gridArea="outfit" backgroundColor="gray.900"> <Box gridArea="preview" backgroundColor="gray.900">
<OutfitPreview <OutfitPreview
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</Box> </Box>
<Box gridArea="search" boxShadow="sm"> <Box gridArea="itemsAndSearch">
<Box px="5" py="3"> <ItemsAndSearchPanels
<SearchToolbar
query={searchQuery}
queryRef={searchQueryRef}
onChange={setSearchQuery}
onMoveFocusDownToResults={(e) => {
if (firstSearchResultRef.current) {
firstSearchResultRef.current.focus();
e.preventDefault();
}
}}
/>
</Box>
</Box>
{searchQuery ? (
<Box
gridArea="items"
position="relative"
overflow="auto"
key="search-panel"
ref={searchContainerRef}
>
<Box px="4" py="5">
<SearchPanel
query={searchQuery}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
firstSearchResultRef={firstSearchResultRef}
onMoveFocusUpToQuery={(e) => {
if (searchQueryRef.current) {
searchQueryRef.current.focus();
e.preventDefault();
}
}}
/>
</Box>
</Box>
) : (
<Box
gridArea="items"
position="relative"
overflow="auto"
key="items-panel"
>
<Box px="5" py="5">
<ItemsPanel
loading={loading} loading={loading}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</Box> </Box>
</Box>
)}
</Grid> </Grid>
</Box> </Box>
</> </>
); );
} }
function SearchToolbar({
query,
queryRef,
onChange,
onMoveFocusDownToResults,
}) {
return (
<InputGroup>
<InputLeftElement>
<Icon name="search" color="gray.400" />
</InputLeftElement>
<Input
placeholder="Search for items to add…"
focusBorderColor="green.600"
color="green.800"
value={query}
ref={queryRef}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
onChange("");
e.target.blur();
} else if (e.key === "ArrowDown") {
onMoveFocusDownToResults(e);
}
}}
/>
{query && (
<InputRightElement>
<IconButton
icon="close"
color="gray.400"
variant="ghost"
variantColor="green"
aria-label="Clear search"
onClick={() => onChange("")}
// Big style hacks here!
height="calc(100% - 2px)"
marginRight="2px"
/>
</InputRightElement>
)}
</InputGroup>
);
}
export default WardrobePage; export default WardrobePage;