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 { items={items}
opacity: 1; outfitState={outfitState}
height: auto; dispatchToOutfit={dispatchToOutfit}
} />
</ItemZoneGroupTransitioner>
&-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}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box>
</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 (
<ItemListContainer> <Box mb="10">
<TransitionGroup component={null}> <Heading2 mx="1">{zoneLabel}</Heading2>
{items.map((item) => ( <ItemListContainer>
<CSSTransition <TransitionGroup component={null}>
key={item.id} {items.map((item) => (
classNames={css` <ItemTransitioner key={item.id}>
&-exit { <label>
opacity: 1; <VisuallyHidden
height: auto; as="input"
} type="radio"
aria-labelledby={`${zoneLabel}-item-${item.id}-name`}
&-exit-active { name={zoneLabel}
opacity: 0; value={item.id}
height: 0 !important; checked={outfitState.wornItemIds.includes(item.id)}
margin-top: 0 !important; onChange={onChange}
margin-bottom: 0 !important; onClick={onClick}
transition: all 0.5s; onKeyUp={(e) => {
} if (e.key === " ") {
`} onClick(e);
timeout={500} }
onExit={(e) => { }}
e.style.height = e.offsetHeight + "px"; />
}} <Item
> item={item}
<label> itemNameId={`${zoneLabel}-item-${item.id}-name`}
<VisuallyHidden outfitState={outfitState}
as="input" dispatchToOutfit={dispatchToOutfit}
type="radio" />
aria-labelledby={`${name}-item-${item.id}-name`} </label>
name={name} </ItemTransitioner>
value={item.id} ))}
checked={outfitState.wornItemIds.includes(item.id)} </TransitionGroup>
onChange={onChange} </ItemListContainer>
onClick={onToggle} </Box>
onKeyUp={(e) => {
if (e.key === " ") {
onToggle(e);
}
}}
/>
<Item
item={item}
itemNameId={`${name}-item-${item.id}-name`}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</label>
</CSSTransition>
))}
</TransitionGroup>
</ItemListContainer>
); );
} }
/**
* 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,7 +167,20 @@ function OutfitHeading({ outfitState, dispatchToOutfit }) {
<EditablePreview /> <EditablePreview />
<EditableInput /> <EditableInput />
{!isEditing && ( {!isEditing && (
<OutfitNameEditButton onRequestEdit={onRequestEdit} /> <PseudoBox
opacity="0"
transition="opacity 0.5s"
_groupHover={{ opacity: "1" }}
onClick={onRequestEdit}
>
<IconButton
icon="edit"
variant="link"
color="green.600"
aria-label="Edit outfit name"
title="Edit outfit name"
/>
</PseudoBox>
)} )}
</Flex> </Flex>
)} )}
@ -186,22 +191,65 @@ function OutfitHeading({ outfitState, dispatchToOutfit }) {
); );
} }
function OutfitNameEditButton({ onRequestEdit }) { /**
* ItemZoneGroupTransitioner manages the fade-out transition when an
* ItemZoneGroup is removed. See react-transition-group docs for more info!
*/
function ItemZoneGroupTransitioner({ children }) {
return ( return (
<PseudoBox <CSSTransition
opacity="0" classNames={css`
transition="opacity 0.5s" &-exit {
_groupHover={{ opacity: "1" }} opacity: 1;
onClick={onRequestEdit} 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";
}}
> >
<IconButton {children}
icon="edit" </CSSTransition>
variant="link" );
color="green.600" }
aria-label="Edit outfit name"
title="Edit outfit name" /**
/> * ItemTransitioner manages the fade-out transition when an Item is removed.
</PseudoBox> * 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>
); );
} }