ItemsPanel docs + cleanup

This commit is contained in:
Matt Dunn-Rankin 2020-04-26 01:14:31 -07:00
parent 5ae60d91d2
commit 22d75d90c1
2 changed files with 179 additions and 122 deletions

View file

@ -45,6 +45,19 @@ export function Item({ item, itemNameId, outfitState, dispatchToOutfit }) {
); );
} }
/**
* ItemSkeleton is a placeholder for when an Item is loading.
*/
function ItemSkeleton() {
return (
<ItemContainer>
<Skeleton width="50px" height="50px" />
<Box width="3" />
<Skeleton height="1.5rem" width="12rem" />
</ItemContainer>
);
}
/** /**
* ItemContainer is the outermost element of an `Item`. * ItemContainer is the outermost element of an `Item`.
* *
@ -205,18 +218,14 @@ export function ItemListContainer({ children }) {
} }
/** /**
* ItemListSkeleton is a loading placeholder for an `ItemListContainer` and the * ItemListSkeleton is a placeholder for when an ItemListContainer and its
* `Item`s inside! * Items are loading.
*/ */
export function ItemListSkeleton({ count }) { export function ItemListSkeleton({ count }) {
return ( return (
<ItemListContainer> <ItemListContainer>
{Array.from({ length: count }).map((_, i) => ( {Array.from({ length: count }).map((_, i) => (
<ItemContainer key={i}> <ItemSkeleton key={i} />
<Skeleton width="50px" height="50px" />
<Box width="3" />
<Skeleton height="1.5rem" width="12rem" />
</ItemContainer>
))} ))}
</ItemListContainer> </ItemListContainer>
); );

View file

@ -16,6 +16,19 @@ import { CSSTransition, TransitionGroup } from "react-transition-group";
import { Delay, Heading1, Heading2 } from "./util"; import { Delay, Heading1, Heading2 } from "./util";
import { Item, ItemListContainer, ItemListSkeleton } from "./Item"; import { Item, ItemListContainer, ItemListSkeleton } from "./Item";
/**
* 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 }) { function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
const { zonesAndItems } = outfitState; const { zonesAndItems } = outfitState;
@ -28,53 +41,23 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
/> />
</Box> </Box>
<Flex direction="column"> <Flex direction="column">
{loading && {loading ? (
[1, 2, 3].map((i) => ( <>
<Box key={i} mb="10"> <ItemZoneGroupSkeleton />
<Delay> <ItemZoneGroupSkeleton />
<Box px="1"> <ItemZoneGroupSkeleton />
<Skeleton height="2.3rem" width="12rem" /> </>
</Box> ) : (
<ItemListSkeleton count={3} />
</Delay>
</Box>
))}
{!loading && (
<TransitionGroup component={null}> <TransitionGroup component={null}>
{zonesAndItems.map(({ zoneLabel, items }) => ( {zonesAndItems.map(({ zoneLabel, items }) => (
<CSSTransition <ItemZoneGroupTransitioner key={zoneLabel}>
key={zoneLabel} <ItemZoneGroup
classNames={css` zoneLabel={zoneLabel}
&-exit {
opacity: 1;
height: auto;
}
&-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";
}}
>
<Box mb="10">
<Box px="1">
<Heading2>{zoneLabel}</Heading2>
</Box>
<ItemRadioList
name={zoneLabel}
items={items} items={items}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</Box> </ItemZoneGroupTransitioner>
</CSSTransition>
))} ))}
</TransitionGroup> </TransitionGroup>
)} )}
@ -83,15 +66,24 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
); );
} }
function ItemRadioList({ name, items, outfitState, dispatchToOutfit }) { /**
* 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 }) {
// onChange is fired when the radio button becomes checked, not unchecked!
const onChange = (e) => { const onChange = (e) => {
const itemId = e.target.value; const itemId = e.target.value;
dispatchToOutfit({ type: "wearItem", itemId }); dispatchToOutfit({ type: "wearItem", itemId });
}; };
const onToggle = (e) => { // Clicking the radio button when already selected deselects it - this is how
// Clicking the radio button when already selected deselects it - this is // you can select none!
// how you can select none! const onClick = (e) => {
const itemId = e.target.value; const itemId = e.target.value;
if (outfitState.wornItemIds.includes(itemId)) { if (outfitState.wornItemIds.includes(itemId)) {
// We need the event handler to finish before this, so that simulated // We need the event handler to finish before this, so that simulated
@ -104,60 +96,60 @@ function ItemRadioList({ name, items, outfitState, dispatchToOutfit }) {
}; };
return ( return (
<Box mb="10">
<Heading2 mx="1">{zoneLabel}</Heading2>
<ItemListContainer> <ItemListContainer>
<TransitionGroup component={null}> <TransitionGroup component={null}>
{items.map((item) => ( {items.map((item) => (
<CSSTransition <ItemTransitioner key={item.id}>
key={item.id}
classNames={css`
&-exit {
opacity: 1;
height: auto;
}
&-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";
}}
>
<label> <label>
<VisuallyHidden <VisuallyHidden
as="input" as="input"
type="radio" type="radio"
aria-labelledby={`${name}-item-${item.id}-name`} aria-labelledby={`${zoneLabel}-item-${item.id}-name`}
name={name} name={zoneLabel}
value={item.id} value={item.id}
checked={outfitState.wornItemIds.includes(item.id)} checked={outfitState.wornItemIds.includes(item.id)}
onChange={onChange} onChange={onChange}
onClick={onToggle} onClick={onClick}
onKeyUp={(e) => { onKeyUp={(e) => {
if (e.key === " ") { if (e.key === " ") {
onToggle(e); onClick(e);
} }
}} }}
/> />
<Item <Item
item={item} item={item}
itemNameId={`${name}-item-${item.id}-name`} itemNameId={`${zoneLabel}-item-${item.id}-name`}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</label> </label>
</CSSTransition> </ItemTransitioner>
))} ))}
</TransitionGroup> </TransitionGroup>
</ItemListContainer> </ItemListContainer>
</Box>
); );
} }
/**
* ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading.
*/
function ItemZoneGroupSkeleton() {
return (
<Box mb="10">
<Delay>
<Skeleton mx="1" height="2.3rem" width="12rem" />
<ItemListSkeleton count={3} />
</Delay>
</Box>
);
}
/**
* OutfitHeading is an editable outfit name, as a big pretty page heading!
*/
function OutfitHeading({ outfitState, dispatchToOutfit }) { function OutfitHeading({ outfitState, dispatchToOutfit }) {
return ( return (
<Box> <Box>
@ -175,19 +167,6 @@ function OutfitHeading({ outfitState, dispatchToOutfit }) {
<EditablePreview /> <EditablePreview />
<EditableInput /> <EditableInput />
{!isEditing && ( {!isEditing && (
<OutfitNameEditButton onRequestEdit={onRequestEdit} />
)}
</Flex>
)}
</Editable>
</Heading1>
</PseudoBox>
</Box>
);
}
function OutfitNameEditButton({ onRequestEdit }) {
return (
<PseudoBox <PseudoBox
opacity="0" opacity="0"
transition="opacity 0.5s" transition="opacity 0.5s"
@ -202,6 +181,75 @@ function OutfitNameEditButton({ onRequestEdit }) {
title="Edit outfit name" title="Edit outfit name"
/> />
</PseudoBox> </PseudoBox>
)}
</Flex>
)}
</Editable>
</Heading1>
</PseudoBox>
</Box>
);
}
/**
* ItemZoneGroupTransitioner manages the fade-out transition when an
* ItemZoneGroup is removed. See react-transition-group docs for more info!
*/
function ItemZoneGroupTransitioner({ children }) {
return (
<CSSTransition
classNames={css`
&-exit {
opacity: 1;
height: auto;
}
&-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";
}}
>
{children}
</CSSTransition>
);
}
/**
* ItemTransitioner manages the fade-out transition when an Item is removed.
* See react-transition-group docs for more info!
*/
function ItemTransitioner({ children }) {
return (
<CSSTransition
classNames={css`
&-exit {
opacity: 1;
height: auto;
}
&-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";
}}
>
{children}
</CSSTransition>
); );
} }