impress-2020/src/app/WardrobePage/ItemsPanel.js

298 lines
9.1 KiB
JavaScript
Raw Normal View History

import React from "react";
import { css } from "@emotion/css";
import {
Box,
Editable,
EditablePreview,
EditableInput,
Flex,
IconButton,
Skeleton,
Tooltip,
2020-04-25 15:25:51 -07:00
VisuallyHidden,
2020-12-25 09:08:33 -08:00
} from "@chakra-ui/react";
import { EditIcon, QuestionIcon } from "@chakra-ui/icons";
import { CSSTransition, TransitionGroup } from "react-transition-group";
2020-07-20 21:41:26 -07:00
import { Delay, Heading1, Heading2 } from "../util";
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
2020-04-26 01:14:31 -07:00
/**
* ItemsPanel shows the items in the current outfit, and lets the user toggle
* between them! It also shows an editable outfit name, to help set context.
*
* Note that this component provides an effective 1 unit of padding around
* itself, which is uncommon in this app: we usually prefer to let parents
* control the spacing!
*
* This is because Item has padding, but it's generally not visible; so, to
* *look* aligned with the other elements like the headings, the headings need
* to have extra padding. Essentially: while the Items _do_ stretch out the
* full width of the container, it doesn't look like it!
*/
function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
const { zonesAndItems, incompatibleItems } = outfitState;
return (
<Box>
<Box px="1">
<OutfitHeading
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
<Flex direction="column">
2020-04-26 01:14:31 -07:00
{loading ? (
<ItemZoneGroupsSkeleton itemCount={outfitState.allItemIds.length} />
2020-04-26 01:14:31 -07:00
) : (
<TransitionGroup component={null}>
{zonesAndItems.map(({ zoneLabel, items }) => (
<CSSTransition key={zoneLabel} {...fadeOutAndRollUpTransition}>
2020-04-26 01:14:31 -07:00
<ItemZoneGroup
zoneLabel={zoneLabel}
items={items}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</CSSTransition>
))}
{incompatibleItems.length > 0 && (
<ItemZoneGroup
zoneLabel="Incompatible"
afterHeader={
<Tooltip
label="These items don't fit this pet"
placement="top"
openDelay={100}
>
<QuestionIcon fontSize="sm" />
</Tooltip>
}
items={incompatibleItems}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
isDisabled
/>
)}
</TransitionGroup>
)}
</Flex>
</Box>
);
}
2020-04-26 01:14:31 -07:00
/**
* ItemZoneGroup shows the items for a particular zone, and lets the user
* toggle between them.
*
* For each item, it renders a <label> with a visually-hidden radio button and
* the Item component (which will visually reflect the radio's state). This
* makes the list screen-reader- and keyboard-accessible!
*/
function ItemZoneGroup({
zoneLabel,
items,
outfitState,
dispatchToOutfit,
isDisabled = false,
afterHeader = null,
}) {
2020-04-26 01:14:31 -07:00
// onChange is fired when the radio button becomes checked, not unchecked!
2020-04-25 15:25:51 -07:00
const onChange = (e) => {
const itemId = e.target.value;
dispatchToOutfit({ type: "wearItem", itemId });
};
2020-04-26 01:14:31 -07:00
// Clicking the radio button when already selected deselects it - this is how
// you can select none!
const onClick = (e) => {
2020-04-25 15:25:51 -07:00
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);
}
};
const onRemove = React.useCallback(
(itemId) => {
dispatchToOutfit({ type: "removeItem", itemId });
},
[dispatchToOutfit]
);
2020-04-25 15:25:51 -07:00
return (
2020-04-26 01:14:31 -07:00
<Box mb="10">
<Heading2 display="flex" alignItems="center" mx="1">
{zoneLabel}
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
</Heading2>
2020-04-26 01:14:31 -07:00
<ItemListContainer>
<TransitionGroup component={null}>
{items.map((item) => {
const itemNameId =
zoneLabel.replace(/ /g, "-") + `-item-${item.id}-name`;
const itemNode = (
<Item
item={item}
itemNameId={itemNameId}
isWorn={
!isDisabled && outfitState.wornItemIds.includes(item.id)
}
isInOutfit={outfitState.allItemIds.includes(item.id)}
onRemove={onRemove}
isDisabled={isDisabled}
/>
);
return (
<CSSTransition key={item.id} {...fadeOutAndRollUpTransition}>
{isDisabled ? (
itemNode
) : (
<label>
<VisuallyHidden
as="input"
type="radio"
aria-labelledby={itemNameId}
name={zoneLabel}
value={item.id}
checked={outfitState.wornItemIds.includes(item.id)}
onChange={onChange}
onClick={onClick}
onKeyUp={(e) => {
if (e.key === " ") {
onClick(e);
}
}}
/>
{itemNode}
</label>
)}
</CSSTransition>
);
})}
2020-04-26 01:14:31 -07:00
</TransitionGroup>
</ItemListContainer>
</Box>
);
}
2020-04-25 23:17:59 -07:00
/**
* ItemZoneGroupSkeletons is a placeholder for when the items are loading.
*
* We try to match the approximate size of the incoming data! This is
* especially nice for when you start with a fresh pet from the homepage, so
* we don't show skeleton items that just clear away!
*/
function ItemZoneGroupsSkeleton({ itemCount }) {
const groups = [];
for (let i = 0; i < itemCount; i++) {
// NOTE: I initially wrote this to return groups of 3, which looks good for
// outfit shares I think, but looks bad for pet loading... once shares
// become a more common use case, it might be useful to figure out how
// to differentiate these cases and show 1-per-group for pets, but
// maybe more for built outfits?
groups.push(<ItemZoneGroupSkeleton key={i} itemCount={1} />);
}
return groups;
}
2020-04-26 01:14:31 -07:00
/**
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
*/
function ItemZoneGroupSkeleton({ itemCount }) {
2020-04-26 01:14:31 -07:00
return (
<Box mb="10">
<Delay>
<Skeleton
mx="1"
// 2.25rem font size, 1.25rem line height
height={`${2.25 * 1.25}rem`}
width="12rem"
/>
<ItemListSkeleton count={itemCount} />
2020-04-26 01:14:31 -07:00
</Delay>
</Box>
2020-04-25 15:25:51 -07:00
);
}
2020-04-26 01:14:31 -07:00
/**
* OutfitHeading is an editable outfit name, as a big pretty page heading!
*/
function OutfitHeading({ outfitState, dispatchToOutfit }) {
return (
<Box>
<Box role="group" d="inline-block" position="relative" width="100%">
<Heading1 mb="6">
<Editable
value={outfitState.name}
2020-05-10 00:21:04 -07:00
placeholder="Untitled outfit"
onChange={(value) =>
dispatchToOutfit({ type: "rename", outfitName: value })
}
>
{({ isEditing, onEdit }) => (
<Flex align="flex-top">
<EditablePreview />
<EditableInput />
{!isEditing && (
<Box
2020-04-26 01:14:31 -07:00
opacity="0"
transition="opacity 0.5s"
_groupHover={{ opacity: "1" }}
onClick={onEdit}
2020-04-26 01:14:31 -07:00
>
<IconButton
icon={<EditIcon />}
2020-04-26 01:14:31 -07:00
variant="link"
aria-label="Edit outfit name"
title="Edit outfit name"
/>
</Box>
)}
</Flex>
)}
</Editable>
</Heading1>
</Box>
</Box>
);
}
2020-04-26 01:14:31 -07:00
/**
* fadeOutAndRollUpTransition is the props for a CSSTransition, to manage the
* fade-out and height decrease when an Item or ItemZoneGroup is removed.
*
* Note that this _cannot_ be implemented as a wrapper component that returns a
* CSSTransition. This is because the CSSTransition must be the direct child of
* the TransitionGroup, and a wrapper breaks the parent-child relationship.
*
2020-04-26 01:14:31 -07:00
* See react-transition-group docs for more info!
*/
const fadeOutAndRollUpTransition = {
classNames: css`
&-exit {
opacity: 1;
height: auto;
}
2020-04-26 01:14:31 -07:00
&-exit-active {
opacity: 0;
height: 0 !important;
margin-top: 0 !important;
margin-bottom: 0 !important;
transition: all 0.5s;
}
`,
timeout: 500,
onExit: (e) => {
e.style.height = e.offsetHeight + "px";
},
};
export default ItemsPanel;