diff --git a/package.json b/package.json index 6a3d290..80d6ba5 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "apollo-server-core": "^2.12.0", "apollo-server-env": "^2.4.3", "dataloader": "^2.0.0", + "emotion": "^10.0.27", "emotion-theming": "^10.0.27", "graphql": "^15.0.0", "immer": "^6.0.3", diff --git a/src/ItemList.js b/src/ItemList.js index aec43f8..78c93c8 100644 --- a/src/ItemList.js +++ b/src/ItemList.js @@ -1,78 +1,41 @@ import React from "react"; -import { CSSTransition, TransitionGroup } from "react-transition-group"; +import { css } from "emotion"; import { Box, Flex, IconButton, Image, - PseudoBox, Skeleton, Tooltip, + useTheme, } from "@chakra-ui/core"; import "./ItemList.css"; -function ItemList({ items, outfitState, dispatchToOutfit }) { - return ( - - - {items.map((item) => ( - { - e.style.height = e.offsetHeight + "px"; - }} - > - - - - - ))} - - - ); +export function ItemListContainer({ children }) { + return {children}; } -function ItemListSkeleton({ count }) { +export function ItemListSkeleton({ count }) { return ( {Array.from({ length: count }).map((_, i) => ( - - - + ))} ); } -function Item({ item, outfitState, dispatchToOutfit }) { - const { wornItemIds, allItemIds } = outfitState; - - const isWorn = wornItemIds.includes(item.id); +export function Item({ item, itemNameId, outfitState, dispatchToOutfit }) { + const { allItemIds } = outfitState; const isInOutfit = allItemIds.includes(item.id); + const theme = useTheme(); return ( - - dispatchToOutfit({ - type: isWorn ? "unwearItem" : "wearItem", - itemId: item.id, - }) - } - > - + + - {item.name} + {item.name} {isInOutfit && ( @@ -82,90 +45,129 @@ function Item({ item, outfitState, dispatchToOutfit }) { variant="ghost" color="gray.400" onClick={(e) => { - e.stopPropagation(); dispatchToOutfit({ type: "removeItem", itemId: item.id }); + e.preventDefault(); }} opacity="0" transitionProperty="opacity color" transitionDuration="0.2s" - _groupHover={{ - opacity: 1, - transitionDuration: "0.5s", - }} - _hover={{ - opacity: 1, - color: "gray.800", - backgroundColor: "gray.200", - }} - _focus={{ - opacity: 1, - color: "gray.800", - backgroundColor: "gray.200", - }} + className={css` + &:hover, + &:focus, + input:focus + .item-container & { + opacity: 1; + color: ${theme.colors.gray["800"]}; + backgroundcolor: ${theme.colors.gray["200"]}; + } + `} /> )} - + ); } function ItemSkeleton() { return ( - - - - + + + + + + + + ); +} + +function ItemContainer({ children }) { + const theme = useTheme(); + + return ( + + {children} ); } -function ItemThumbnail({ src, isWorn }) { +function ItemThumbnail({ src }) { + const theme = useTheme(); return ( - - + ); } -function ItemName({ children, isWorn }) { +function ItemName({ children, ...props }) { + const theme = useTheme(); + return ( - {children} - + ); } - -export default ItemList; -export { ItemListSkeleton }; diff --git a/src/ItemsPanel.js b/src/ItemsPanel.js index 88c1213..5541e81 100644 --- a/src/ItemsPanel.js +++ b/src/ItemsPanel.js @@ -8,11 +8,12 @@ import { IconButton, PseudoBox, Skeleton, + VisuallyHidden, } from "@chakra-ui/core"; import { CSSTransition, TransitionGroup } from "react-transition-group"; import { Delay, Heading1, Heading2 } from "./util"; -import ItemList, { ItemListSkeleton } from "./ItemList"; +import { ItemListContainer, Item, ItemListSkeleton } from "./ItemList"; import "./ItemsPanel.css"; @@ -21,16 +22,20 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) { return ( - + + + {loading && [1, 2, 3].map((i) => ( - + + + @@ -47,8 +52,11 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) { }} > - {zoneLabel} - + {zoneLabel} + + { + const itemId = e.target.value; + dispatchToOutfit({ type: "wearItem", itemId }); + }; + + const onToggle = (e) => { + // Clicking the radio button when already selected deselects it - this is + // how you can select none! + const itemId = e.target.value; + if (outfitState.wornItemIds.includes(itemId)) { + // We need the event handler to finish before this, so that simulated + // events don't just come back around and undo it - but we can't just + // solve that with `preventDefault`, because it breaks the radio's + // intended visual updates when we unwear. So, we `setTimeout` to do it + // after all event handlers resolve! + setTimeout(() => dispatchToOutfit({ type: "unwearItem", itemId }), 0); + } + }; + + return ( + + + {items.map((item) => ( + { + e.style.height = e.offsetHeight + "px"; + }} + > + + + ))} + + + ); +} + function OutfitHeading({ outfitState, dispatchToOutfit }) { return ( diff --git a/src/SearchPanel.js b/src/SearchPanel.js index 1381d4d..695725e 100644 --- a/src/SearchPanel.js +++ b/src/SearchPanel.js @@ -1,17 +1,18 @@ import React from "react"; import gql from "graphql-tag"; -import { Box, Text } from "@chakra-ui/core"; +import { Box, Text, VisuallyHidden } from "@chakra-ui/core"; import { useQuery } from "@apollo/react-hooks"; import { Delay, Heading1, useDebounce } from "./util"; -import ItemList, { ItemListSkeleton } from "./ItemList"; +import { ItemListContainer, ItemListSkeleton, Item } from "./ItemList"; import { itemAppearanceFragment } from "./OutfitPreview"; function SearchPanel({ query, outfitState, dispatchToOutfit, - getScrollParent, + firstSearchResultRef, + onMoveFocusUpToQuery, }) { return ( @@ -20,13 +21,20 @@ function SearchPanel({ query={query} outfitState={outfitState} dispatchToOutfit={dispatchToOutfit} - getScrollParent={getScrollParent} + firstSearchResultRef={firstSearchResultRef} + onMoveFocusUpToQuery={onMoveFocusUpToQuery} /> ); } -function SearchResults({ query, outfitState, dispatchToOutfit }) { +function SearchResults({ + query, + outfitState, + dispatchToOutfit, + firstSearchResultRef, + onMoveFocusUpToQuery, +}) { const { speciesId, colorId } = outfitState; const debouncedQuery = useDebounce(query, 300, { waitForFirstPause: true }); @@ -159,19 +167,72 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) { ); } + const onChange = (e) => { + const itemId = e.target.value; + const willBeWorn = e.target.checked; + if (willBeWorn) { + dispatchToOutfit({ type: "wearItem", itemId }); + } else { + dispatchToOutfit({ type: "unwearItem", itemId }); + } + }; + + const goToPrevItem = (e) => { + const prevLabel = e.target.closest("label").previousSibling; + if (prevLabel) { + prevLabel.querySelector("input[type=checkbox]").focus(); + e.preventDefault(); + } else { + // If we're at the top of the list, move back up to the search box! + onMoveFocusUpToQuery(e); + } + }; + + const goToNextItem = (e) => { + const nextLabel = e.target.closest("label").nextSibling; + if (nextLabel) { + nextLabel.querySelector("input[type=checkbox]").focus(); + e.preventDefault(); + } + }; + return ( - + + {items.map((item, index) => ( + + ))} + {items && loading && } ); } -function ScrollTracker({ children, query, threshold, onScrolledToBottom }) { +function ScrollTracker({ children, threshold, onScrolledToBottom }) { const containerRef = React.useRef(); const scrollParent = React.useRef(); @@ -209,7 +270,7 @@ function ScrollTracker({ children, query, threshold, onScrolledToBottom }) { }; }, [onScroll]); - return {children}; + return
{children}
; } export default SearchPanel; diff --git a/src/SpeciesColorPicker.js b/src/SpeciesColorPicker.js index bd5b887..5358ad7 100644 --- a/src/SpeciesColorPicker.js +++ b/src/SpeciesColorPicker.js @@ -74,7 +74,6 @@ function SpeciesColorPicker({ if (allValidSpeciesColorPairs.has(pair)) { dispatchToOutfit({ type: "changeColor", colorId: e.target.value }); } else { - console.log(pair, Array.from(allValidSpeciesColorPairs)); const species = allSpecies.find((s) => s.id === speciesId); const color = allColors.find((c) => c.id === colorId); toast({ @@ -91,7 +90,6 @@ function SpeciesColorPicker({ if (allValidSpeciesColorPairs.has(pair)) { dispatchToOutfit({ type: "changeSpecies", speciesId: e.target.value }); } else { - console.log(pair, Array.from(allValidSpeciesColorPairs)); const species = allSpecies.find((s) => s.id === speciesId); const color = allColors.find((c) => c.id === colorId); toast({ diff --git a/src/WardrobePage.js b/src/WardrobePage.js index cd74be8..d9ae9e3 100644 --- a/src/WardrobePage.js +++ b/src/WardrobePage.js @@ -21,6 +21,8 @@ function WardrobePage() { const [searchQuery, setSearchQuery] = React.useState(""); const toast = useToast(); const searchContainerRef = React.useRef(); + const searchQueryRef = React.useRef(); + const firstSearchResultRef = React.useRef(); React.useEffect(() => { if (error) { @@ -72,27 +74,50 @@ function WardrobePage() {
- + { + if (firstSearchResultRef.current) { + firstSearchResultRef.current.focus(); + e.preventDefault(); + } + }} + /> {searchQuery ? ( - + { + if (searchQueryRef.current) { + searchQueryRef.current.focus(); + e.preventDefault(); + } + }} /> ) : ( - + @@ -118,11 +148,14 @@ function SearchToolbar({ query, onChange }) { 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); } }} /> diff --git a/yarn.lock b/yarn.lock index 78b0f1f..e0c7e9a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3974,6 +3974,16 @@ create-ecdh@^4.0.0: bn.js "^4.1.0" elliptic "^6.0.0" +create-emotion@^10.0.27: + version "10.0.27" + resolved "https://registry.yarnpkg.com/create-emotion/-/create-emotion-10.0.27.tgz#cb4fa2db750f6ca6f9a001a33fbf1f6c46789503" + integrity sha512-fIK73w82HPPn/RsAij7+Zt8eCE8SptcJ3WoRMfxMtjteYxud8GDTKKld7MYwAX2TVhrw29uR1N/bVGxeStHILg== + dependencies: + "@emotion/cache" "^10.0.27" + "@emotion/serialize" "^0.11.15" + "@emotion/sheet" "0.9.4" + "@emotion/utils" "0.11.3" + create-hash@^1.1.0, create-hash@^1.1.2: version "1.2.0" resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" @@ -4684,6 +4694,14 @@ emotion-theming@^10.0.27: "@emotion/weak-memoize" "0.2.5" hoist-non-react-statics "^3.3.0" +emotion@^10.0.27: + version "10.0.27" + resolved "https://registry.yarnpkg.com/emotion/-/emotion-10.0.27.tgz#f9ca5df98630980a23c819a56262560562e5d75e" + integrity sha512-2xdDzdWWzue8R8lu4G76uWX5WhyQuzATon9LmNeCy/2BHVC6dsEpfhN1a0qhELgtDVdjyEA6J8Y/VlI5ZnaH0g== + dependencies: + babel-plugin-emotion "^10.0.27" + create-emotion "^10.0.27" + encodeurl@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"