add item removal, with smooth transitions!

This commit is contained in:
Matt Dunn-Rankin 2020-04-25 00:22:49 -07:00
parent d39c781f3f
commit 5264509b53
7 changed files with 224 additions and 109 deletions

12
src/ItemList.css Normal file
View file

@ -0,0 +1,12 @@
.item-list-row-exit {
opacity: 1;
height: auto;
}
.item-list-row-exit-active {
opacity: 0;
height: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
transition: all 0.5s;
}

View file

@ -1,19 +1,42 @@
import React from "react"; import React from "react";
import { Box, Image, PseudoBox, Stack, Skeleton } from "@chakra-ui/core"; import { CSSTransition, TransitionGroup } from "react-transition-group";
import {
Box,
Flex,
IconButton,
Image,
PseudoBox,
Stack,
Skeleton,
Tooltip,
} from "@chakra-ui/core";
function ItemList({ items, wornItemIds, dispatchToOutfit }) { import "./ItemList.css";
function ItemList({ items, outfitState, dispatchToOutfit }) {
return ( return (
<Stack spacing="3"> <Flex direction="column">
<TransitionGroup component={null}>
{items.map((item) => ( {items.map((item) => (
<Box key={item.id}> <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={item} item={item}
isWorn={wornItemIds.includes(item.id)} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</Box> </PseudoBox>
</CSSTransition>
))} ))}
</Stack> </TransitionGroup>
</Flex>
); );
} }
@ -29,7 +52,12 @@ function ItemListSkeleton({ count }) {
); );
} }
function Item({ item, isWorn, dispatchToOutfit }) { function Item({ item, outfitState, dispatchToOutfit }) {
const { wornItemIds, allItemIds } = outfitState;
const isWorn = wornItemIds.includes(item.id);
const isInOutfit = allItemIds.includes(item.id);
return ( return (
<PseudoBox <PseudoBox
role="group" role="group"
@ -46,6 +74,38 @@ function Item({ item, isWorn, dispatchToOutfit }) {
<ItemThumbnail src={item.thumbnailUrl} isWorn={isWorn} /> <ItemThumbnail src={item.thumbnailUrl} isWorn={isWorn} />
<Box width="3" /> <Box width="3" />
<ItemName isWorn={isWorn}>{item.name}</ItemName> <ItemName isWorn={isWorn}>{item.name}</ItemName>
<Box flexGrow="1" />
{isInOutfit && (
<Tooltip label="Remove" placement="top">
<IconButton
icon="delete"
aria-label="Remove from outfit"
variant="ghost"
color="gray.400"
onClick={(e) => {
e.stopPropagation();
dispatchToOutfit({ type: "removeItem", itemId: item.id });
}}
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",
}}
/>
</Tooltip>
)}
</PseudoBox> </PseudoBox>
); );
} }

12
src/ItemsPanel.css Normal file
View file

@ -0,0 +1,12 @@
.items-panel-zone-exit {
opacity: 1;
height: auto;
}
.items-panel-zone-exit-active {
opacity: 0;
height: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
transition: all 0.5s;
}

115
src/ItemsPanel.js Normal file
View file

@ -0,0 +1,115 @@
import React from "react";
import {
Box,
Editable,
EditablePreview,
EditableInput,
Flex,
IconButton,
PseudoBox,
Skeleton,
} from "@chakra-ui/core";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import { Delay, Heading1, Heading2 } from "./util";
import ItemList, { ItemListSkeleton } from "./ItemList";
import "./ItemsPanel.css";
function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
const { zonesAndItems, wornItemIds } = outfitState;
return (
<Box color="green.800">
<OutfitHeading
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
<Flex direction="column">
{loading &&
[1, 2, 3].map((i) => (
<Box key={i}>
<Delay>
<Skeleton height="2.3rem" width="12rem" />
<ItemListSkeleton count={3} />
</Delay>
</Box>
))}
{!loading && (
<TransitionGroup component={null}>
{zonesAndItems.map(({ zone, items }) => (
<CSSTransition
key={zone.id}
classNames="items-panel-zone"
timeout={500}
onExit={(e) => {
e.style.height = e.offsetHeight + "px";
}}
>
<Box mb="10">
<Heading2>{zone.label}</Heading2>
<ItemList
items={items}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</CSSTransition>
))}
</TransitionGroup>
)}
</Flex>
</Box>
);
}
function OutfitHeading({ outfitState, dispatchToOutfit }) {
return (
<Box>
<PseudoBox role="group" d="inline-block" position="relative" width="100%">
<Heading1 mb="6">
<Editable
value={outfitState.name}
placeholder="Untitled outfit (click to edit)"
onChange={(value) =>
dispatchToOutfit({ type: "rename", outfitName: value })
}
>
{({ isEditing, onRequestEdit }) => (
<>
<EditablePreview />
<EditableInput />
{!isEditing && (
<OutfitNameEditButton onRequestEdit={onRequestEdit} />
)}
</>
)}
</Editable>
</Heading1>
</PseudoBox>
</Box>
);
}
function OutfitNameEditButton({ onRequestEdit }) {
return (
<PseudoBox
d="inline-block"
opacity="0"
transition="opacity 0.5s"
_groupHover={{ opacity: "1" }}
onClick={onRequestEdit}
position="absolute"
>
<IconButton
icon="edit"
variant="link"
color="green.600"
aria-label="Edit outfit name"
title="Edit outfit name"
/>
</PseudoBox>
);
}
export default ItemsPanel;

View file

@ -94,7 +94,7 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) {
return ( return (
<ItemList <ItemList
items={items} items={items}
wornItemIds={wornItemIds} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
); );

View file

@ -1,9 +1,6 @@
import React from "react"; import React from "react";
import { import {
Box, Box,
Editable,
EditablePreview,
EditableInput,
Grid, Grid,
Icon, Icon,
IconButton, IconButton,
@ -11,14 +8,10 @@ import {
InputGroup, InputGroup,
InputLeftElement, InputLeftElement,
InputRightElement, InputRightElement,
PseudoBox,
Skeleton,
Stack,
useToast, useToast,
} from "@chakra-ui/core"; } from "@chakra-ui/core";
import { Delay, Heading1, Heading2 } from "./util"; import ItemsPanel from "./ItemsPanel";
import ItemList, { ItemListSkeleton } from "./ItemList";
import OutfitPreview from "./OutfitPreview"; import OutfitPreview from "./OutfitPreview";
import SearchPanel from "./SearchPanel"; import SearchPanel from "./SearchPanel";
import useOutfitState from "./useOutfitState.js"; import useOutfitState from "./useOutfitState.js";
@ -129,90 +122,4 @@ function SearchToolbar({ query, onChange }) {
); );
} }
function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
const { zonesAndItems, wornItemIds } = outfitState;
return (
<Box color="green.800">
<OutfitHeading
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
<Stack spacing="10">
{loading &&
[1, 2, 3].map((i) => (
<Box key={i}>
<Delay>
<Skeleton height="2.3rem" width="12rem" mb="3" />
<ItemListSkeleton count={3} />
</Delay>
</Box>
))}
{!loading &&
zonesAndItems.map(({ zone, items }) => (
<Box key={zone.id}>
<Heading2 mb="3">{zone.label}</Heading2>
<ItemList
items={items}
wornItemIds={items
.map((i) => i.id)
.filter((id) => wornItemIds.includes(id))}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
))}
</Stack>
</Box>
);
}
function OutfitHeading({ outfitState, dispatchToOutfit }) {
return (
<Box>
<PseudoBox role="group" d="inline-block" position="relative" width="100%">
<Heading1 mb="6">
<Editable
value={outfitState.name}
placeholder="Untitled outfit (click to edit)"
onChange={(value) =>
dispatchToOutfit({ type: "rename", outfitName: value })
}
>
{({ isEditing, onRequestEdit }) => (
<>
<EditablePreview />
<EditableInput />
{!isEditing && (
<OutfitNameEditButton onRequestEdit={onRequestEdit} />
)}
</>
)}
</Editable>
</Heading1>
</PseudoBox>
</Box>
);
}
function OutfitNameEditButton({ onRequestEdit }) {
return (
<PseudoBox
d="inline-block"
opacity="0"
transition="opacity 0.5s"
_groupHover={{ opacity: "1" }}
onClick={onRequestEdit}
position="absolute"
>
<IconButton
icon="edit"
variant="link"
color="green.600"
aria-label="Edit outfit name"
title="Edit outfit name"
/>
</PseudoBox>
);
}
export default WardrobePage; export default WardrobePage;

View file

@ -133,6 +133,15 @@ const outfitStateReducer = (apolloClient) => (baseState, action) => {
wornItemIds.delete(itemId); wornItemIds.delete(itemId);
closetedItemIds.add(itemId); closetedItemIds.add(itemId);
}); });
case "removeItem":
return produce(baseState, (state) => {
const { wornItemIds, closetedItemIds } = state;
const { itemId } = action;
// Remove this item from both the worn set and the closet.
wornItemIds.delete(itemId);
closetedItemIds.delete(itemId);
});
default: default:
throw new Error(`unexpected action ${action}`); throw new Error(`unexpected action ${action}`);
} }