2020-04-25 00:22:49 -07:00
|
|
|
import React from "react";
|
2021-01-03 19:11:46 -08:00
|
|
|
import { ClassNames } from "@emotion/react";
|
2020-04-25 00:22:49 -07:00
|
|
|
import {
|
|
|
|
Box,
|
|
|
|
Editable,
|
|
|
|
EditablePreview,
|
|
|
|
EditableInput,
|
|
|
|
Flex,
|
|
|
|
IconButton,
|
|
|
|
Skeleton,
|
2020-09-01 17:17:45 -07:00
|
|
|
Tooltip,
|
2020-04-25 15:25:51 -07:00
|
|
|
VisuallyHidden,
|
2020-12-25 09:08:33 -08:00
|
|
|
} from "@chakra-ui/react";
|
2020-09-01 17:17:45 -07:00
|
|
|
import { EditIcon, QuestionIcon } from "@chakra-ui/icons";
|
2020-04-25 00:22:49 -07:00
|
|
|
import { CSSTransition, TransitionGroup } from "react-transition-group";
|
|
|
|
|
2020-07-20 21:41:26 -07:00
|
|
|
import { Delay, Heading1, Heading2 } from "../util";
|
2020-08-05 00:25:25 -07:00
|
|
|
import Item, { ItemListContainer, ItemListSkeleton } from "./Item";
|
2021-01-04 22:29:39 -08:00
|
|
|
import WIPCallout from "../components/WIPCallout";
|
2020-04-25 00:22:49 -07:00
|
|
|
|
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!
|
|
|
|
*/
|
2020-04-25 00:22:49 -07:00
|
|
|
function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
|
2020-09-01 17:17:45 -07:00
|
|
|
const { zonesAndItems, incompatibleItems } = outfitState;
|
2020-04-25 00:22:49 -07:00
|
|
|
|
|
|
|
return (
|
2021-01-03 19:11:46 -08:00
|
|
|
<ClassNames>
|
|
|
|
{({ css }) => (
|
|
|
|
<Box>
|
|
|
|
<Box px="1">
|
|
|
|
<OutfitHeading
|
|
|
|
outfitState={outfitState}
|
|
|
|
dispatchToOutfit={dispatchToOutfit}
|
|
|
|
/>
|
|
|
|
</Box>
|
|
|
|
<Flex direction="column">
|
|
|
|
{loading ? (
|
|
|
|
<ItemZoneGroupsSkeleton
|
|
|
|
itemCount={outfitState.allItemIds.length}
|
2020-09-01 17:17:45 -07:00
|
|
|
/>
|
2021-01-03 19:11:46 -08:00
|
|
|
) : (
|
|
|
|
<TransitionGroup component={null}>
|
|
|
|
{zonesAndItems.map(({ zoneLabel, items }) => (
|
|
|
|
<CSSTransition
|
|
|
|
key={zoneLabel}
|
|
|
|
{...fadeOutAndRollUpTransition(css)}
|
|
|
|
>
|
|
|
|
<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>
|
2020-09-01 17:17:45 -07:00
|
|
|
)}
|
2021-01-03 19:11:46 -08:00
|
|
|
</Flex>
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</ClassNames>
|
2020-04-25 00:22:49 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
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!
|
|
|
|
*/
|
2020-09-01 17:17:45 -07:00
|
|
|
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);
|
|
|
|
}
|
|
|
|
};
|
|
|
|
|
2020-09-24 07:07:05 -07:00
|
|
|
const onRemove = React.useCallback(
|
|
|
|
(itemId) => {
|
|
|
|
dispatchToOutfit({ type: "removeItem", itemId });
|
|
|
|
},
|
|
|
|
[dispatchToOutfit]
|
|
|
|
);
|
|
|
|
|
2020-04-25 15:25:51 -07:00
|
|
|
return (
|
2021-01-03 19:11:46 -08:00
|
|
|
<ClassNames>
|
|
|
|
{({ css }) => (
|
|
|
|
<Box mb="10">
|
|
|
|
<Heading2 display="flex" alignItems="center" mx="1">
|
|
|
|
{zoneLabel}
|
|
|
|
{afterHeader && <Box marginLeft="2">{afterHeader}</Box>}
|
|
|
|
</Heading2>
|
|
|
|
<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}
|
|
|
|
/>
|
|
|
|
);
|
2020-09-01 17:17:45 -07:00
|
|
|
|
2021-01-03 19:11:46 -08:00
|
|
|
return (
|
|
|
|
<CSSTransition
|
|
|
|
key={item.id}
|
|
|
|
{...fadeOutAndRollUpTransition(css)}
|
|
|
|
>
|
|
|
|
{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>
|
|
|
|
);
|
|
|
|
})}
|
|
|
|
</TransitionGroup>
|
|
|
|
</ItemListContainer>
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</ClassNames>
|
2020-04-26 01:14:31 -07:00
|
|
|
);
|
|
|
|
}
|
2020-04-25 23:17:59 -07:00
|
|
|
|
2020-05-19 14:43:21 -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.
|
|
|
|
*/
|
2020-05-19 14:43:21 -07:00
|
|
|
function ItemZoneGroupSkeleton({ itemCount }) {
|
2020-04-26 01:14:31 -07:00
|
|
|
return (
|
|
|
|
<Box mb="10">
|
|
|
|
<Delay>
|
2020-05-19 14:43:21 -07:00
|
|
|
<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!
|
|
|
|
*/
|
2020-04-25 00:22:49 -07:00
|
|
|
function OutfitHeading({ outfitState, dispatchToOutfit }) {
|
|
|
|
return (
|
2021-01-04 22:29:39 -08:00
|
|
|
<Flex align="flex-start" justify="space-between">
|
|
|
|
<Box marginRight="4">
|
|
|
|
<Box role="group" d="inline-block" position="relative" width="100%">
|
|
|
|
<Heading1 mb="6">
|
|
|
|
<Editable
|
Fix "Untitled outfit" on open outfit in new window
Previously, when you navigated directly to an outfit by typing the URL into the browser or following an external link, the name would stay as "Untitled outfit", even after the outfit loaded.
This was because, when you render an `Editable` Chakra component with `value={undefined}`, it permanently enters "uncontrolled" mode, and providing a value later doesn't change that.
But tbh passing `undefined` down from outfit state wasn't my intention! But yeah, turns out the `?.` operator returns `undefined` rather than `null`, which I guess makes sense!
So, I've fixed this on both ends. I'm now passing more `null`s down via outfit state, because I think that's a more expected value in general.
But also, for the `Editable`, I'm making a point of passing in an empty string as `value`, so that this component will be resilient to upstream changes in the future. (It's pretty brittle to _depend_ on the difference between `null` and `undefined`, as we saw here 😅)
2021-01-17 08:19:47 -08:00
|
|
|
// Make sure not to ever pass `undefined` into here, or else the
|
|
|
|
// component enters uncontrolled mode, and changing the value
|
|
|
|
// later won't fix it!
|
|
|
|
value={outfitState.name || ""}
|
2021-01-04 22:29:39 -08:00
|
|
|
placeholder="Untitled outfit"
|
|
|
|
onChange={(value) =>
|
|
|
|
dispatchToOutfit({ type: "rename", outfitName: value })
|
|
|
|
}
|
|
|
|
>
|
|
|
|
{({ isEditing, onEdit }) => (
|
|
|
|
<Flex align="flex-top">
|
|
|
|
<EditablePreview />
|
|
|
|
<EditableInput />
|
|
|
|
{!isEditing && (
|
|
|
|
<Box
|
|
|
|
opacity="0"
|
|
|
|
transition="opacity 0.5s"
|
|
|
|
_groupHover={{ opacity: "1" }}
|
|
|
|
onClick={onEdit}
|
|
|
|
>
|
|
|
|
<IconButton
|
|
|
|
icon={<EditIcon />}
|
|
|
|
variant="link"
|
|
|
|
aria-label="Edit outfit name"
|
|
|
|
title="Edit outfit name"
|
|
|
|
/>
|
|
|
|
</Box>
|
|
|
|
)}
|
|
|
|
</Flex>
|
|
|
|
)}
|
|
|
|
</Editable>
|
|
|
|
</Heading1>
|
|
|
|
</Box>
|
2020-07-20 21:32:42 -07:00
|
|
|
</Box>
|
2021-01-04 22:29:39 -08:00
|
|
|
{outfitState.id && (
|
|
|
|
<WIPCallout
|
|
|
|
details={`To save a new version of this outfit, use Classic DTI. But you can still play around in here for now!`}
|
|
|
|
marginTop="1"
|
|
|
|
placement="bottom-end"
|
|
|
|
>
|
|
|
|
Saved outfits are WIP!
|
|
|
|
</WIPCallout>
|
|
|
|
)}
|
|
|
|
</Flex>
|
2020-04-25 00:22:49 -07:00
|
|
|
);
|
|
|
|
}
|
|
|
|
|
2020-04-26 01:14:31 -07:00
|
|
|
/**
|
2020-04-27 10:14:13 -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!
|
|
|
|
*/
|
2021-01-03 19:11:46 -08:00
|
|
|
const fadeOutAndRollUpTransition = (css) => ({
|
2020-04-27 10:14:13 -07:00
|
|
|
classNames: css`
|
|
|
|
&-exit {
|
|
|
|
opacity: 1;
|
|
|
|
height: auto;
|
|
|
|
}
|
2020-04-26 01:14:31 -07:00
|
|
|
|
2020-04-27 10:14:13 -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";
|
|
|
|
},
|
2021-01-03 19:11:46 -08:00
|
|
|
});
|
2020-04-25 00:22:49 -07:00
|
|
|
|
|
|
|
export default ItemsPanel;
|