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;