Merge branch 'items-refactor'

This commit is contained in:
Matt Dunn-Rankin 2020-04-25 20:22:07 -07:00
commit a3cc035821
7 changed files with 304 additions and 121 deletions

View file

@ -16,6 +16,7 @@
"apollo-server-core": "^2.12.0", "apollo-server-core": "^2.12.0",
"apollo-server-env": "^2.4.3", "apollo-server-env": "^2.4.3",
"dataloader": "^2.0.0", "dataloader": "^2.0.0",
"emotion": "^10.0.27",
"emotion-theming": "^10.0.27", "emotion-theming": "^10.0.27",
"graphql": "^15.0.0", "graphql": "^15.0.0",
"immer": "^6.0.3", "immer": "^6.0.3",

View file

@ -1,78 +1,41 @@
import React from "react"; import React from "react";
import { CSSTransition, TransitionGroup } from "react-transition-group"; import { css } from "emotion";
import { import {
Box, Box,
Flex, Flex,
IconButton, IconButton,
Image, Image,
PseudoBox,
Skeleton, Skeleton,
Tooltip, Tooltip,
useTheme,
} from "@chakra-ui/core"; } from "@chakra-ui/core";
import "./ItemList.css"; import "./ItemList.css";
function ItemList({ items, outfitState, dispatchToOutfit }) { export function ItemListContainer({ children }) {
return ( return <Flex direction="column">{children}</Flex>;
<Flex direction="column">
<TransitionGroup component={null}>
{items.map((item) => (
<CSSTransition
key={item.id}
classNames="item-list-row"
timeout={500}
onExit={(e) => {
e.style.height = e.offsetHeight + "px";
}}
>
<PseudoBox mb="2" mt="2">
<Item
item={item}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</PseudoBox>
</CSSTransition>
))}
</TransitionGroup>
</Flex>
);
} }
function ItemListSkeleton({ count }) { export function ItemListSkeleton({ count }) {
return ( return (
<Flex direction="column"> <Flex direction="column">
{Array.from({ length: count }).map((_, i) => ( {Array.from({ length: count }).map((_, i) => (
<Box key={i} mb="2" mt="2"> <ItemSkeleton key={i} />
<ItemSkeleton />
</Box>
))} ))}
</Flex> </Flex>
); );
} }
function Item({ item, outfitState, dispatchToOutfit }) { export function Item({ item, itemNameId, outfitState, dispatchToOutfit }) {
const { wornItemIds, allItemIds } = outfitState; const { allItemIds } = outfitState;
const isWorn = wornItemIds.includes(item.id);
const isInOutfit = allItemIds.includes(item.id); const isInOutfit = allItemIds.includes(item.id);
const theme = useTheme();
return ( return (
<PseudoBox <ItemContainer>
role="group" <ItemThumbnail src={item.thumbnailUrl} />
d="flex"
alignItems="center"
cursor="pointer"
onClick={() =>
dispatchToOutfit({
type: isWorn ? "unwearItem" : "wearItem",
itemId: item.id,
})
}
>
<ItemThumbnail src={item.thumbnailUrl} isWorn={isWorn} />
<Box width="3" /> <Box width="3" />
<ItemName isWorn={isWorn}>{item.name}</ItemName> <ItemName id={itemNameId}>{item.name}</ItemName>
<Box flexGrow="1" /> <Box flexGrow="1" />
{isInOutfit && ( {isInOutfit && (
<Tooltip label="Remove" placement="top"> <Tooltip label="Remove" placement="top">
@ -82,90 +45,129 @@ function Item({ item, outfitState, dispatchToOutfit }) {
variant="ghost" variant="ghost"
color="gray.400" color="gray.400"
onClick={(e) => { onClick={(e) => {
e.stopPropagation();
dispatchToOutfit({ type: "removeItem", itemId: item.id }); dispatchToOutfit({ type: "removeItem", itemId: item.id });
e.preventDefault();
}} }}
opacity="0" opacity="0"
transitionProperty="opacity color" transitionProperty="opacity color"
transitionDuration="0.2s" transitionDuration="0.2s"
_groupHover={{ className={css`
opacity: 1, &:hover,
transitionDuration: "0.5s", &:focus,
}} input:focus + .item-container & {
_hover={{ opacity: 1;
opacity: 1, color: ${theme.colors.gray["800"]};
color: "gray.800", backgroundcolor: ${theme.colors.gray["200"]};
backgroundColor: "gray.200", }
}} `}
_focus={{
opacity: 1,
color: "gray.800",
backgroundColor: "gray.200",
}}
/> />
</Tooltip> </Tooltip>
)} )}
</PseudoBox> </ItemContainer>
); );
} }
function ItemSkeleton() { function ItemSkeleton() {
return ( return (
<ItemContainer>
<Box d="flex" alignItems="center"> <Box d="flex" alignItems="center">
<Skeleton width="50px" height="50px" /> <Skeleton width="50px" height="50px" />
<Box width="3" /> <Box width="3" />
<Skeleton height="1.5rem" width="12rem" /> <Skeleton height="1.5rem" width="12rem" />
</Box> </Box>
</ItemContainer>
); );
} }
function ItemThumbnail({ src, isWorn }) { function ItemContainer({ children }) {
const theme = useTheme();
return ( return (
<PseudoBox <Box
p="1"
my="1"
rounded="lg"
d="flex"
alignItems="center"
cursor="pointer"
border="1px"
borderColor="transparent"
className={
"item-container " +
css`
&:hover,
input:focus + & {
background-color: ${theme.colors.gray["100"]};
}
input:active + & {
border-color: ${theme.colors.green["400"]};
}
input:checked:focus + & {
border-color: ${theme.colors.green["800"]};
}
`
}
>
{children}
</Box>
);
}
function ItemThumbnail({ src }) {
const theme = useTheme();
return (
<Box
rounded="lg" rounded="lg"
boxShadow="md" boxShadow="md"
border="1px" border="1px"
borderColor={isWorn ? "green.700" : "green.700"} borderColor="green.700"
opacity={isWorn ? 1 : 0.7}
width="50px" width="50px"
height="50px" height="50px"
overflow="hidden" overflow="hidden"
transition="all 0.15s" transition="all 0.15s"
transformOrigin="center" transformOrigin="center"
transform={isWorn ? null : "scale(0.8)"} transform="scale(0.8)"
_groupHover={ className={css`
!isWorn && { .item-container:hover & {
opacity: 0.9, opacity: 0.9;
transform: "scale(0.9)", transform: scale(0.9);
borderColor: "green.600", bordercolor: ${theme.colors.green["600"]};
} }
input:checked + .item-container & {
opacity: 1;
transform: none;
} }
`}
> >
<Image src={src} /> <Image src={src} />
</PseudoBox> </Box>
); );
} }
function ItemName({ children, isWorn }) { function ItemName({ children, ...props }) {
const theme = useTheme();
return ( return (
<PseudoBox <Box
fontSize="md" fontSize="md"
fontWeight={isWorn && "bold"}
color="green.800" color="green.800"
transition="all 0.15s" transition="all 0.15s"
opacity={isWorn ? 1 : 0.8} className={css`
_groupHover={ .item-container:hover & {
!isWorn && { opacity: 0.9;
color: "green.800", font-weight: ${theme.fontWeights.medium};
fontWeight: "medium",
opacity: 0.9,
}
}
>
{children}
</PseudoBox>
);
} }
export default ItemList; input:checked + .item-container & {
export { ItemListSkeleton }; opacity: 1;
font-weight: ${theme.fontWeights.bold};
}
`}
{...props}
>
{children}
</Box>
);
}

View file

@ -8,11 +8,12 @@ import {
IconButton, IconButton,
PseudoBox, PseudoBox,
Skeleton, Skeleton,
VisuallyHidden,
} from "@chakra-ui/core"; } from "@chakra-ui/core";
import { CSSTransition, TransitionGroup } from "react-transition-group"; import { CSSTransition, TransitionGroup } from "react-transition-group";
import { Delay, Heading1, Heading2 } from "./util"; import { Delay, Heading1, Heading2 } from "./util";
import ItemList, { ItemListSkeleton } from "./ItemList"; import { ItemListContainer, Item, ItemListSkeleton } from "./ItemList";
import "./ItemsPanel.css"; import "./ItemsPanel.css";
@ -21,16 +22,20 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
return ( return (
<Box color="green.800"> <Box color="green.800">
<Box px="1">
<OutfitHeading <OutfitHeading
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</Box>
<Flex direction="column"> <Flex direction="column">
{loading && {loading &&
[1, 2, 3].map((i) => ( [1, 2, 3].map((i) => (
<Box key={i} mb="10"> <Box key={i} mb="10">
<Delay> <Delay>
<Box px="1">
<Skeleton height="2.3rem" width="12rem" /> <Skeleton height="2.3rem" width="12rem" />
</Box>
<ItemListSkeleton count={3} /> <ItemListSkeleton count={3} />
</Delay> </Delay>
</Box> </Box>
@ -47,8 +52,11 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
}} }}
> >
<Box mb="10"> <Box mb="10">
<Box px="1">
<Heading2>{zoneLabel}</Heading2> <Heading2>{zoneLabel}</Heading2>
<ItemList </Box>
<ItemRadioList
name={zoneLabel}
items={items} items={items}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
@ -63,6 +71,68 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
); );
} }
function ItemRadioList({ name, items, outfitState, dispatchToOutfit }) {
const onChange = (e) => {
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 (
<ItemListContainer>
<TransitionGroup component={null}>
{items.map((item) => (
<CSSTransition
key={item.id}
classNames="item-list-row"
timeout={500}
onExit={(e) => {
e.style.height = e.offsetHeight + "px";
}}
>
<label>
<VisuallyHidden
as="input"
type="radio"
aria-labelledby={`${name}-item-${item.id}-name`}
name={name}
value={item.id}
checked={outfitState.wornItemIds.includes(item.id)}
onChange={onChange}
onClick={onToggle}
onKeyUp={(e) => {
if (e.key === " ") {
onToggle(e);
}
}}
/>
<Item
item={item}
itemNameId={`${name}-item-${item.id}-name`}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</label>
</CSSTransition>
))}
</TransitionGroup>
</ItemListContainer>
);
}
function OutfitHeading({ outfitState, dispatchToOutfit }) { function OutfitHeading({ outfitState, dispatchToOutfit }) {
return ( return (
<Box> <Box>

View file

@ -1,17 +1,18 @@
import React from "react"; import React from "react";
import gql from "graphql-tag"; 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 { useQuery } from "@apollo/react-hooks";
import { Delay, Heading1, useDebounce } from "./util"; import { Delay, Heading1, useDebounce } from "./util";
import ItemList, { ItemListSkeleton } from "./ItemList"; import { ItemListContainer, ItemListSkeleton, Item } from "./ItemList";
import { itemAppearanceFragment } from "./OutfitPreview"; import { itemAppearanceFragment } from "./OutfitPreview";
function SearchPanel({ function SearchPanel({
query, query,
outfitState, outfitState,
dispatchToOutfit, dispatchToOutfit,
getScrollParent, firstSearchResultRef,
onMoveFocusUpToQuery,
}) { }) {
return ( return (
<Box color="green.800"> <Box color="green.800">
@ -20,13 +21,20 @@ function SearchPanel({
query={query} query={query}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
getScrollParent={getScrollParent} firstSearchResultRef={firstSearchResultRef}
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
/> />
</Box> </Box>
); );
} }
function SearchResults({ query, outfitState, dispatchToOutfit }) { function SearchResults({
query,
outfitState,
dispatchToOutfit,
firstSearchResultRef,
onMoveFocusUpToQuery,
}) {
const { speciesId, colorId } = outfitState; const { speciesId, colorId } = outfitState;
const debouncedQuery = useDebounce(query, 300, { waitForFirstPause: true }); 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 ( return (
<ScrollTracker threshold={300} onScrolledToBottom={onScrolledToBottom}> <ScrollTracker threshold={300} onScrolledToBottom={onScrolledToBottom}>
<ItemList <ItemListContainer>
items={items} {items.map((item, index) => (
<label key={item.id}>
<VisuallyHidden
as="input"
type="checkbox"
aria-label={`Wear "${item.name}"`}
value={item.id}
checked={outfitState.wornItemIds.includes(item.id)}
ref={index === 0 ? firstSearchResultRef : null}
onChange={onChange}
onKeyDown={(e) => {
if (e.key === "ArrowUp") {
goToPrevItem(e);
} else if (e.key === "ArrowDown") {
goToNextItem(e);
} else if (e.key === "Escape") {
onMoveFocusUpToQuery(e);
}
}}
/>
<Item
item={item}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</label>
))}
</ItemListContainer>
{items && loading && <ItemListSkeleton count={8} />} {items && loading && <ItemListSkeleton count={8} />}
</ScrollTracker> </ScrollTracker>
); );
} }
function ScrollTracker({ children, query, threshold, onScrolledToBottom }) { function ScrollTracker({ children, threshold, onScrolledToBottom }) {
const containerRef = React.useRef(); const containerRef = React.useRef();
const scrollParent = React.useRef(); const scrollParent = React.useRef();
@ -209,7 +270,7 @@ function ScrollTracker({ children, query, threshold, onScrolledToBottom }) {
}; };
}, [onScroll]); }, [onScroll]);
return <Box ref={containerRef}>{children}</Box>; return <div ref={containerRef}>{children}</div>;
} }
export default SearchPanel; export default SearchPanel;

View file

@ -74,7 +74,6 @@ function SpeciesColorPicker({
if (allValidSpeciesColorPairs.has(pair)) { if (allValidSpeciesColorPairs.has(pair)) {
dispatchToOutfit({ type: "changeColor", colorId: e.target.value }); dispatchToOutfit({ type: "changeColor", colorId: e.target.value });
} else { } else {
console.log(pair, Array.from(allValidSpeciesColorPairs));
const species = allSpecies.find((s) => s.id === speciesId); const species = allSpecies.find((s) => s.id === speciesId);
const color = allColors.find((c) => c.id === colorId); const color = allColors.find((c) => c.id === colorId);
toast({ toast({
@ -91,7 +90,6 @@ function SpeciesColorPicker({
if (allValidSpeciesColorPairs.has(pair)) { if (allValidSpeciesColorPairs.has(pair)) {
dispatchToOutfit({ type: "changeSpecies", speciesId: e.target.value }); dispatchToOutfit({ type: "changeSpecies", speciesId: e.target.value });
} else { } else {
console.log(pair, Array.from(allValidSpeciesColorPairs));
const species = allSpecies.find((s) => s.id === speciesId); const species = allSpecies.find((s) => s.id === speciesId);
const color = allColors.find((c) => c.id === colorId); const color = allColors.find((c) => c.id === colorId);
toast({ toast({

View file

@ -21,6 +21,8 @@ function WardrobePage() {
const [searchQuery, setSearchQuery] = React.useState(""); const [searchQuery, setSearchQuery] = React.useState("");
const toast = useToast(); const toast = useToast();
const searchContainerRef = React.useRef(); const searchContainerRef = React.useRef();
const searchQueryRef = React.useRef();
const firstSearchResultRef = React.useRef();
React.useEffect(() => { React.useEffect(() => {
if (error) { if (error) {
@ -72,27 +74,50 @@ function WardrobePage() {
</Box> </Box>
<Box gridArea="search" boxShadow="sm"> <Box gridArea="search" boxShadow="sm">
<Box px="5" py="3"> <Box px="5" py="3">
<SearchToolbar query={searchQuery} onChange={setSearchQuery} /> <SearchToolbar
query={searchQuery}
queryRef={searchQueryRef}
onChange={setSearchQuery}
onMoveFocusDownToResults={(e) => {
if (firstSearchResultRef.current) {
firstSearchResultRef.current.focus();
e.preventDefault();
}
}}
/>
</Box> </Box>
</Box> </Box>
{searchQuery ? ( {searchQuery ? (
<Box <Box
gridArea="items" gridArea="items"
position="relative"
overflow="auto" overflow="auto"
key="search-panel" key="search-panel"
ref={searchContainerRef} ref={searchContainerRef}
> >
<Box px="5" py="5"> <Box px="4" py="5">
<SearchPanel <SearchPanel
query={searchQuery} query={searchQuery}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
firstSearchResultRef={firstSearchResultRef}
onMoveFocusUpToQuery={(e) => {
if (searchQueryRef.current) {
searchQueryRef.current.focus();
e.preventDefault();
}
}}
/> />
</Box> </Box>
</Box> </Box>
) : ( ) : (
<Box gridArea="items" overflow="auto" key="items-panel"> <Box
gridArea="items"
position="relative"
overflow="auto"
key="items-panel"
>
<Box px="5" py="5"> <Box px="5" py="5">
<ItemsPanel <ItemsPanel
loading={loading} loading={loading}
@ -107,7 +132,12 @@ function WardrobePage() {
); );
} }
function SearchToolbar({ query, onChange }) { function SearchToolbar({
query,
queryRef,
onChange,
onMoveFocusDownToResults,
}) {
return ( return (
<InputGroup> <InputGroup>
<InputLeftElement> <InputLeftElement>
@ -118,11 +148,14 @@ function SearchToolbar({ query, onChange }) {
focusBorderColor="green.600" focusBorderColor="green.600"
color="green.800" color="green.800"
value={query} value={query}
ref={queryRef}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
onChange(""); onChange("");
e.target.blur(); e.target.blur();
} else if (e.key === "ArrowDown") {
onMoveFocusDownToResults(e);
} }
}} }}
/> />

View file

@ -3974,6 +3974,16 @@ create-ecdh@^4.0.0:
bn.js "^4.1.0" bn.js "^4.1.0"
elliptic "^6.0.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: create-hash@^1.1.0, create-hash@^1.1.2:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/create-hash/-/create-hash-1.2.0.tgz#889078af11a63756bcfb59bd221996be3a9ef196" 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" "@emotion/weak-memoize" "0.2.5"
hoist-non-react-statics "^3.3.0" 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: encodeurl@~1.0.2:
version "1.0.2" version "1.0.2"
resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59"