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.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
|
||||||
}
|
}
|
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,
|
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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in a new issue