create ItemsAndSearchPanel, simplify search refs
This commit is contained in:
parent
5371cbbd4a
commit
776bc33329
4 changed files with 211 additions and 160 deletions
4
.vscode/settings.json
vendored
4
.vscode/settings.json
vendored
|
@ -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
|
||||
}
|
146
src/app/ItemsAndSearchPanels.js
Normal file
146
src/app/ItemsAndSearchPanels.js
Normal 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;
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
||||
{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
|
||||
<Box gridArea="itemsAndSearch">
|
||||
<ItemsAndSearchPanels
|
||||
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;
|
||||
|
|
Loading…
Reference in a new issue