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-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",

View file

@ -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 (
<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>
);
export function ItemListContainer({ children }) {
return <Flex direction="column">{children}</Flex>;
}
function ItemListSkeleton({ count }) {
export function ItemListSkeleton({ count }) {
return (
<Flex direction="column">
{Array.from({ length: count }).map((_, i) => (
<Box key={i} mb="2" mt="2">
<ItemSkeleton />
</Box>
<ItemSkeleton key={i} />
))}
</Flex>
);
}
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 (
<PseudoBox
role="group"
d="flex"
alignItems="center"
cursor="pointer"
onClick={() =>
dispatchToOutfit({
type: isWorn ? "unwearItem" : "wearItem",
itemId: item.id,
})
}
>
<ItemThumbnail src={item.thumbnailUrl} isWorn={isWorn} />
<ItemContainer>
<ItemThumbnail src={item.thumbnailUrl} />
<Box width="3" />
<ItemName isWorn={isWorn}>{item.name}</ItemName>
<ItemName id={itemNameId}>{item.name}</ItemName>
<Box flexGrow="1" />
{isInOutfit && (
<Tooltip label="Remove" placement="top">
@ -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"]};
}
`}
/>
</Tooltip>
)}
</PseudoBox>
</ItemContainer>
);
}
function ItemSkeleton() {
return (
<ItemContainer>
<Box d="flex" alignItems="center">
<Skeleton width="50px" height="50px" />
<Box width="3" />
<Skeleton height="1.5rem" width="12rem" />
</Box>
</ItemContainer>
);
}
function ItemThumbnail({ src, isWorn }) {
function ItemContainer({ children }) {
const theme = useTheme();
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"
boxShadow="md"
border="1px"
borderColor={isWorn ? "green.700" : "green.700"}
opacity={isWorn ? 1 : 0.7}
borderColor="green.700"
width="50px"
height="50px"
overflow="hidden"
transition="all 0.15s"
transformOrigin="center"
transform={isWorn ? null : "scale(0.8)"}
_groupHover={
!isWorn && {
opacity: 0.9,
transform: "scale(0.9)",
borderColor: "green.600",
transform="scale(0.8)"
className={css`
.item-container:hover & {
opacity: 0.9;
transform: scale(0.9);
bordercolor: ${theme.colors.green["600"]};
}
input:checked + .item-container & {
opacity: 1;
transform: none;
}
`}
>
<Image src={src} />
</PseudoBox>
</Box>
);
}
function ItemName({ children, isWorn }) {
function ItemName({ children, ...props }) {
const theme = useTheme();
return (
<PseudoBox
<Box
fontSize="md"
fontWeight={isWorn && "bold"}
color="green.800"
transition="all 0.15s"
opacity={isWorn ? 1 : 0.8}
_groupHover={
!isWorn && {
color: "green.800",
fontWeight: "medium",
opacity: 0.9,
className={css`
.item-container:hover & {
opacity: 0.9;
font-weight: ${theme.fontWeights.medium};
}
input:checked + .item-container & {
opacity: 1;
font-weight: ${theme.fontWeights.bold};
}
`}
{...props}
>
{children}
</PseudoBox>
</Box>
);
}
export default ItemList;
export { ItemListSkeleton };

View file

@ -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 (
<Box color="green.800">
<Box px="1">
<OutfitHeading
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
<Flex direction="column">
{loading &&
[1, 2, 3].map((i) => (
<Box key={i} mb="10">
<Delay>
<Box px="1">
<Skeleton height="2.3rem" width="12rem" />
</Box>
<ItemListSkeleton count={3} />
</Delay>
</Box>
@ -47,8 +52,11 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
}}
>
<Box mb="10">
<Box px="1">
<Heading2>{zoneLabel}</Heading2>
<ItemList
</Box>
<ItemRadioList
name={zoneLabel}
items={items}
outfitState={outfitState}
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 }) {
return (
<Box>

View file

@ -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 (
<Box color="green.800">
@ -20,13 +21,20 @@ function SearchPanel({
query={query}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
getScrollParent={getScrollParent}
firstSearchResultRef={firstSearchResultRef}
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
/>
</Box>
);
}
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 (
<ScrollTracker threshold={300} onScrolledToBottom={onScrolledToBottom}>
<ItemList
items={items}
<ItemListContainer>
{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}
dispatchToOutfit={dispatchToOutfit}
/>
</label>
))}
</ItemListContainer>
{items && loading && <ItemListSkeleton count={8} />}
</ScrollTracker>
);
}
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 <Box ref={containerRef}>{children}</Box>;
return <div ref={containerRef}>{children}</div>;
}
export default SearchPanel;

View file

@ -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({

View file

@ -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() {
</Box>
<Box gridArea="search" boxShadow="sm">
<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>
{searchQuery ? (
<Box
gridArea="items"
position="relative"
overflow="auto"
key="search-panel"
ref={searchContainerRef}
>
<Box px="5" py="5">
<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" overflow="auto" key="items-panel">
<Box
gridArea="items"
position="relative"
overflow="auto"
key="items-panel"
>
<Box px="5" py="5">
<ItemsPanel
loading={loading}
@ -107,7 +132,12 @@ function WardrobePage() {
);
}
function SearchToolbar({ query, onChange }) {
function SearchToolbar({
query,
queryRef,
onChange,
onMoveFocusDownToResults,
}) {
return (
<InputGroup>
<InputLeftElement>
@ -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);
}
}}
/>

View file

@ -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"