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.tabSize": 2,
"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,
outfitState,
dispatchToOutfit,
scrollContainerRef,
searchQueryRef,
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 (
<Box
color="green.800"
@ -28,6 +43,7 @@ function SearchPanel({
query={query}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
scrollContainerRef={scrollContainerRef}
firstSearchResultRef={firstSearchResultRef}
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
/>
@ -39,6 +55,7 @@ function SearchResults({
query,
outfitState,
dispatchToOutfit,
scrollContainerRef,
firstSearchResultRef,
onMoveFocusUpToQuery,
}) {
@ -142,6 +159,8 @@ function SearchResults({
}
}, [loading, isEndOfResults, fetchMore, items.length]);
useScrollTracker({ threshold: 300, scrollContainerRef, onScrolledToBottom });
if (resultQuery !== query || (loading && items.length === 0)) {
return (
<Delay ms={500}>
@ -206,7 +225,7 @@ function SearchResults({
};
return (
<ScrollTracker threshold={300} onScrolledToBottom={onScrolledToBottom}>
<>
<ItemListContainer>
{items.map((item, index) => (
<label key={item.id}>
@ -237,14 +256,15 @@ function SearchResults({
))}
</ItemListContainer>
{items && loading && <ItemListSkeleton count={8} />}
</ScrollTracker>
</>
);
}
function ScrollTracker({ children, threshold, onScrolledToBottom }) {
const containerRef = React.useRef();
const scrollParent = React.useRef();
function useScrollTracker({
threshold,
scrollContainerRef,
onScrolledToBottom,
}) {
const onScroll = React.useCallback(
(e) => {
const topEdgeScrollPosition = e.target.scrollTop;
@ -260,31 +280,20 @@ function ScrollTracker({ children, threshold, onScrolledToBottom }) {
);
React.useLayoutEffect(() => {
if (!containerRef.current) {
const scrollContainer = scrollContainerRef.current;
if (!scrollContainer) {
return;
}
scrollParent.current = getScrollParent(containerRef.current);
scrollParent.current.addEventListener("scroll", onScroll);
scrollContainer.addEventListener("scroll", onScroll);
return () => {
if (scrollParent.current) {
scrollParent.current.removeEventListener("scroll", onScroll);
if (scrollContainer) {
scrollContainer.removeEventListener("scroll", onScroll);
}
};
}, [onScroll]);
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`);
}, [onScroll, scrollContainerRef]);
}
export default SearchPanel;

View file

@ -1,30 +1,28 @@
import React from "react";
import {
Box,
Grid,
Icon,
IconButton,
Input,
InputGroup,
InputLeftElement,
InputRightElement,
useToast,
} from "@chakra-ui/core";
import { Box, Grid, useToast } from "@chakra-ui/core";
import { Helmet } from "react-helmet";
import ItemsPanel from "./ItemsPanel";
import ItemsAndSearchPanels from "./ItemsAndSearchPanels";
import OutfitPreview from "./OutfitPreview";
import SearchPanel from "./SearchPanel";
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() {
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
const [searchQuery, setSearchQuery] = React.useState("");
const toast = useToast();
const searchContainerRef = React.useRef();
const searchQueryRef = React.useRef();
const firstSearchResultRef = React.useRef();
const { loading, error, outfitState, dispatchToOutfit } = useOutfitState();
// 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(() => {
if (error) {
console.log(error);
@ -38,12 +36,6 @@ function WardrobePage() {
}
}, [error, toast]);
React.useEffect(() => {
if (searchContainerRef.current) {
searchContainerRef.current.scrollTop = 0;
}
}, [searchQuery]);
return (
<>
<Helmet>
@ -53,18 +45,14 @@ function WardrobePage() {
</Helmet>
<Box position="absolute" top="0" bottom="0" left="0" right="0">
<Grid
// Fullscreen, split into a vertical stack on smaller screens
// or a horizontal stack on larger ones!
templateAreas={{
base: `"outfit"
"search"
"items"`,
lg: `"outfit search"
"outfit items"`,
base: `"preview"
"itemsAndSearch"`,
lg: `"preview itemsAndSearch"`,
}}
templateRows={{
base: "minmax(100px, 1fr) auto minmax(300px, 1fr)",
lg: "auto 1fr",
base: "minmax(100px, 45%) minmax(300px, 55%)",
lg: "100%",
}}
templateColumns={{
base: "100%",
@ -73,117 +61,23 @@ function WardrobePage() {
height="100%"
width="100%"
>
<Box gridArea="outfit" backgroundColor="gray.900">
<Box gridArea="preview" backgroundColor="gray.900">
<OutfitPreview
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
<Box gridArea="search" boxShadow="sm">
<Box px="5" py="3">
<SearchToolbar
query={searchQuery}
queryRef={searchQueryRef}
onChange={setSearchQuery}
onMoveFocusDownToResults={(e) => {
if (firstSearchResultRef.current) {
firstSearchResultRef.current.focus();
e.preventDefault();
}
}}
/>
</Box>
<Box gridArea="itemsAndSearch">
<ItemsAndSearchPanels
loading={loading}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</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}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</Box>
)}
</Grid>
</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;