From 776bc3332922ad0c2ec53dab3b47c6576bafed1b Mon Sep 17 00:00:00 2001 From: Matt Dunn-Rankin Date: Sun, 26 Apr 2020 00:37:58 -0700 Subject: [PATCH] create ItemsAndSearchPanel, simplify search refs --- .vscode/settings.json | 4 +- src/app/ItemsAndSearchPanels.js | 146 ++++++++++++++++++++++++++++ src/app/SearchPanel.js | 59 +++++++----- src/app/WardrobePage.js | 162 ++++++-------------------------- 4 files changed, 211 insertions(+), 160 deletions(-) create mode 100644 src/app/ItemsAndSearchPanels.js diff --git a/.vscode/settings.json b/.vscode/settings.json index 6acc210..721cfca 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -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 } \ No newline at end of file diff --git a/src/app/ItemsAndSearchPanels.js b/src/app/ItemsAndSearchPanels.js new file mode 100644 index 0000000..88bd5d2 --- /dev/null +++ b/src/app/ItemsAndSearchPanels.js @@ -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 ( + + + + + {searchQuery ? ( + + + + + + ) : ( + + + + + + )} + + ); +} + +/** + * 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 ( + + + + + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Escape") { + onChange(""); + e.target.blur(); + } else if (e.key === "ArrowDown") { + onMoveFocusDownToResults(e); + } + }} + /> + {query && ( + + onChange("")} + // Big style hacks here! + height="calc(100% - 2px)" + marginRight="2px" + /> + + )} + + ); +} + +export default ItemsAndSearchPanels; diff --git a/src/app/SearchPanel.js b/src/app/SearchPanel.js index a9cc440..3f5a01d 100644 --- a/src/app/SearchPanel.js +++ b/src/app/SearchPanel.js @@ -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 ( @@ -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 ( @@ -206,7 +225,7 @@ function SearchResults({ }; return ( - + <> {items.map((item, index) => ( {items && loading && } - + ); } -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
{children}
; -} - -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; diff --git a/src/app/WardrobePage.js b/src/app/WardrobePage.js index 6186bfd..8bb7dbd 100644 --- a/src/app/WardrobePage.js +++ b/src/app/WardrobePage.js @@ -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 ( <> @@ -53,18 +45,14 @@ function WardrobePage() { - + - - - { - if (firstSearchResultRef.current) { - firstSearchResultRef.current.focus(); - e.preventDefault(); - } - }} - /> - + + - - {searchQuery ? ( - - - { - if (searchQueryRef.current) { - searchQueryRef.current.focus(); - e.preventDefault(); - } - }} - /> - - - ) : ( - - - - - - )} ); } -function SearchToolbar({ - query, - queryRef, - onChange, - onMoveFocusDownToResults, -}) { - return ( - - - - - onChange(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Escape") { - onChange(""); - e.target.blur(); - } else if (e.key === "ArrowDown") { - onMoveFocusDownToResults(e); - } - }} - /> - {query && ( - - onChange("")} - // Big style hacks here! - height="calc(100% - 2px)" - marginRight="2px" - /> - - )} - - ); -} - export default WardrobePage;