import React from "react"; import { ClassNames } from "@emotion/react"; import { Box, Editable, EditablePreview, EditableInput, Flex, IconButton, Skeleton, Tooltip, VisuallyHidden, Menu, MenuButton, MenuList, MenuItem, Portal, Button, Spinner, useColorModeValue, Modal, ModalContent, ModalOverlay, ModalHeader, ModalBody, ModalFooter, useDisclosure, ModalCloseButton, } from "@chakra-ui/react"; import { CheckIcon, DeleteIcon, EditIcon, QuestionIcon, WarningTwoIcon, } from "@chakra-ui/icons"; import { IoBagCheck } from "react-icons/io5"; import { CSSTransition, TransitionGroup } from "react-transition-group"; import { Delay, ErrorMessage, Heading1, Heading2 } from "../util"; import Item, { ItemListContainer, ItemListSkeleton } from "./Item"; import { BiRename } from "react-icons/bi"; import { IoCloudUploadOutline } from "react-icons/io5"; import { MdMoreVert } from "react-icons/md"; import { buildOutfitUrl } from "./useOutfitState"; import { useDeleteOutfitMutation } from "../loaders/outfits"; /** * 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, outfitSaving, loading, dispatchToOutfit }) { const { altStyleId, zonesAndItems, incompatibleItems } = outfitState; return ( <ClassNames> {({ css }) => ( <Box> <Box px="1"> <OutfitHeading outfitState={outfitState} outfitSaving={outfitSaving} dispatchToOutfit={dispatchToOutfit} /> </Box> <Flex direction="column"> {loading ? ( <ItemZoneGroupsSkeleton itemCount={outfitState.allItemIds.length} /> ) : ( <> <TransitionGroup component={null}> {zonesAndItems.map(({ zoneId, zoneLabel, items }) => ( <CSSTransition key={zoneId} {...fadeOutAndRollUpTransition(css)} > <ItemZoneGroup zoneLabel={zoneLabel} items={items} outfitState={outfitState} dispatchToOutfit={dispatchToOutfit} /> </CSSTransition> ))} </TransitionGroup> {incompatibleItems.length > 0 && ( <ItemZoneGroup zoneLabel="Incompatible" afterHeader={ <Tooltip label={ altStyleId != null ? "Many items don't fit Alt Style pets" : "These items don't fit this pet" } placement="top" openDelay={100} > <QuestionIcon fontSize="sm" /> </Tooltip> } items={incompatibleItems} outfitState={outfitState} dispatchToOutfit={dispatchToOutfit} isDisabled /> )} </> )} </Flex> </Box> )} </ClassNames> ); } /** * 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, }) { // onChange is fired when the radio button becomes checked, not unchecked! const onChange = (e) => { const itemId = e.target.value; dispatchToOutfit({ type: "wearItem", itemId }); }; // Clicking the radio button when already selected deselects it - this is how // you can select none! const onClick = (e) => { 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], ); return ( <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} /> ); 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> ); } /** * 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; } /** * ItemZoneGroupSkeleton is a placeholder for when an ItemZoneGroup is loading. */ function ItemZoneGroupSkeleton({ itemCount }) { 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} /> </Delay> </Box> ); } /** * ShoppingListButton shows the "Shopping list" button, to link to the Shopping= * List page for the items in this outfit. If there are no items being worn, * this is disabled. */ function ShoppingListButton({ outfitState }) { const itemIds = [...outfitState.wornItemIds].sort(); const isDisabled = itemIds.length === 0; let targetUrl = `/items/sources/${itemIds.join(",")}`; if (outfitState.name != null && outfitState.name.trim().length > 0) { const params = new URLSearchParams(); params.append("for", outfitState.name); targetUrl += "?" + params.toString(); } return ( <Tooltip label="Shopping list" placement="top" background="purple.500" color="white" > <IconButton aria-label="Shopping list" as={isDisabled ? "button" : "a"} href={isDisabled ? undefined : targetUrl} target={isDisabled ? undefined : "_blank"} icon={<IoBagCheck />} colorScheme="purple" size="sm" isRound isDisabled={isDisabled} /> </Tooltip> ); } /** * OutfitSavingIndicator shows a Save button, or the "Saved" or "Saving" state, * if the user can save this outfit. If not, this is empty! */ function OutfitSavingIndicator({ outfitSaving }) { const { canSaveOutfit, isNewOutfit, isSaving, latestVersionIsSaved, saveError, saveOutfit, } = outfitSaving; const errorTextColor = useColorModeValue("red.600", "red.400"); if (!canSaveOutfit) { return null; } if (isNewOutfit) { return ( <Button variant="outline" size="sm" isLoading={isSaving} loadingText="Saving…" leftIcon={ <Box // Adjust the visual balance toward the cloud marginBottom="-2px" > <IoCloudUploadOutline /> </Box> } onClick={saveOutfit} data-test-id="wardrobe-save-outfit-button" > Save </Button> ); } if (isSaving) { return ( <Flex align="center" fontSize="xs" data-test-id="wardrobe-outfit-is-saving-indicator" > <Spinner size="xs" marginRight="1.5" // HACK: Not sure why my various centering things always feel wrong... marginBottom="-2px" /> Saving… </Flex> ); } if (latestVersionIsSaved) { return ( <Flex align="center" fontSize="xs" data-test-id="wardrobe-outfit-is-saved-indicator" > <CheckIcon marginRight="1" // HACK: Not sure why my various centering things always feel wrong... marginBottom="-2px" /> Saved </Flex> ); } if (saveError) { return ( <Flex align="center" fontSize="xs" data-test-id="wardrobe-outfit-save-error-indicator" color={errorTextColor} > <WarningTwoIcon marginRight="1" // HACK: Not sure why my various centering things always feel wrong... marginBottom="-2px" /> Error saving </Flex> ); } // The most common way we'll hit this null is when the outfit is changing, // but the debouncing isn't done yet, so it's not saving yet. return null; } /** * OutfitHeading is an editable outfit name, as a big pretty page heading! * It also contains the outfit menu, for saving etc. */ function OutfitHeading({ outfitState, outfitSaving, dispatchToOutfit }) { const { canDeleteOutfit } = outfitSaving; const outfitCopyUrl = buildOutfitUrl(outfitState, { withoutOutfitId: true }); return ( // The Editable wraps everything, including the menu, because the menu has // a Rename option. <Editable // 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 || ""} placeholder="Untitled outfit" onChange={(value) => dispatchToOutfit({ type: "rename", outfitName: value }) } > {({ onEdit }) => ( <Flex align="center" marginBottom="6"> <Box> <Box role="group" d="inline-block" position="relative" width="100%"> <Heading1> <EditablePreview lineHeight="48px" data-test-id="outfit-name" /> <EditableInput lineHeight="48px" /> </Heading1> </Box> </Box> <Box width="4" flex="1 0 auto" /> <OutfitSavingIndicator outfitSaving={outfitSaving} /> <Box width="3" flex="0 0 auto" /> <ShoppingListButton outfitState={outfitState} /> <Box width="2" flex="0 0 auto" /> <Menu placement="bottom-end"> <MenuButton as={IconButton} variant="ghost" icon={<MdMoreVert />} aria-label="Outfit menu" isRound size="sm" fontSize="24px" opacity="0.8" /> <Portal> <MenuList> {outfitState.id && ( <MenuItem icon={<EditIcon />} as="a" href={outfitCopyUrl} target="_blank" > Edit a copy </MenuItem> )} <MenuItem icon={<BiRename />} onClick={() => { // Start the rename after a tick, so finishing up the click // won't just immediately remove focus from the Editable. setTimeout(onEdit, 0); }} > Rename </MenuItem> {canDeleteOutfit && ( <DeleteOutfitMenuItem outfitState={outfitState} /> )} </MenuList> </Portal> </Menu> </Flex> )} </Editable> ); } function DeleteOutfitMenuItem({ outfitState }) { const { id, name } = outfitState; const { isOpen, onOpen, onClose } = useDisclosure(); const { status, error, mutateAsync } = useDeleteOutfitMutation(); return ( <> <MenuItem icon={<DeleteIcon />} onClick={onOpen}> Delete </MenuItem> <Modal isOpen={isOpen} onClose={onClose}> <ModalOverlay /> <ModalContent> <ModalHeader>Delete outfit "{name}"?</ModalHeader> <ModalCloseButton /> <ModalBody> We'll delete this data and remove it from your list of outfits. Links and image embeds pointing to this outfit will break. Is that okay? {status === "error" && ( <ErrorMessage marginTop="1em"> Error deleting outfit: "{error.message}". Try again? </ErrorMessage> )} </ModalBody> <ModalFooter> <Button onClick={onClose}>No, keep this outfit</Button> <Box flex="1 0 auto" width="2" /> <Button colorScheme="red" onClick={() => mutateAsync(id) .then(() => { window.location = "/your-outfits"; }) .catch((e) => { /* handled in error UI */ }) } // We continue to show the loading spinner in the success case, // while we redirect away! isLoading={status === "pending" || status === "success"} > Delete </Button> </ModalFooter> </ModalContent> </Modal> </> ); } /** * 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. * * See react-transition-group docs for more info! */ const fadeOutAndRollUpTransition = (css) => ({ 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"; }, }); export default ItemsPanel;