import React from "react"; import { ClassNames } from "@emotion/react"; import { Box, Tooltip, useColorModeValue, useToken, Wrap, WrapItem, Flex, } from "@chakra-ui/react"; import { WarningTwoIcon } from "@chakra-ui/icons"; import gql from "graphql-tag"; import { useQuery } from "@apollo/client"; function SpeciesFacesPicker({ selectedSpeciesId, selectedColorId, compatibleBodies, couldProbablyModelMoreData, onChange, isLoading, }) { // For basic colors (Blue, Green, Red, Yellow), we just use the hardcoded // data, which is part of the bundle and loads super-fast. For other colors, // we load in all the faces of that color, falling back to basic colors when // absent! // // TODO: Could we move this into our `build-cached-data` script, and just do // the query all the time, and have Apollo happen to satisfy it fast? // The semantics of returning our colorful random set could be weird… const selectedColorIsBasic = colorIsBasic(selectedColorId); const { loading: loadingGQL, error, data, } = useQuery( gql` query SpeciesFacesPicker($selectedColorId: ID!) { color(id: $selectedColorId) { id appliedToAllCompatibleSpecies { id neopetsImageHash species { id } body { id } } } } `, { variables: { selectedColorId }, skip: selectedColorId == null || selectedColorIsBasic, onError: (e) => console.error(e), }, ); const allBodiesAreCompatible = compatibleBodies.some( (body) => body.id === "0", ); const compatibleBodyIds = compatibleBodies.map((body) => body.id); const speciesFacesFromData = data?.color?.appliedToAllCompatibleSpecies || []; const allSpeciesFaces = DEFAULT_SPECIES_FACES.map((defaultSpeciesFace) => { const providedSpeciesFace = speciesFacesFromData.find( (f) => f.species.id === defaultSpeciesFace.speciesId, ); if (providedSpeciesFace) { return { ...defaultSpeciesFace, colorId: selectedColorId, bodyId: providedSpeciesFace.body.id, // If this species/color pair exists, but without an image hash, then // we want to provide a face so that it's enabled, but use the fallback // image even though it's wrong, so that it looks like _something_. neopetsImageHash: providedSpeciesFace.neopetsImageHash || defaultSpeciesFace.neopetsImageHash, }; } else { return defaultSpeciesFace; } }); return ( <Box> <Wrap spacing="0" justify="center"> {allSpeciesFaces.map((speciesFace) => ( <WrapItem key={speciesFace.speciesId}> <SpeciesFaceOption speciesId={speciesFace.speciesId} speciesName={speciesFace.speciesName} colorId={speciesFace.colorId} neopetsImageHash={speciesFace.neopetsImageHash} isSelected={speciesFace.speciesId === selectedSpeciesId} // If the face color doesn't match the current color, this is a // fallback face for an invalid species/color pair. isValid={ speciesFace.colorId === selectedColorId || selectedColorIsBasic } bodyIsCompatible={ allBodiesAreCompatible || compatibleBodyIds.includes(speciesFace.bodyId) } couldProbablyModelMoreData={couldProbablyModelMoreData} onChange={onChange} isLoading={isLoading || loadingGQL} /> </WrapItem> ))} </Wrap> {error && ( <Flex color="yellow.500" fontSize="xs" marginTop="1" textAlign="center" width="100%" align="flex-start" justify="center" > <WarningTwoIcon marginTop="0.4em" marginRight="1" /> <Box> Error loading this color's pet photos. <br /> Check your connection and try again. </Box> </Flex> )} </Box> ); } const SpeciesFaceOption = React.memo( ({ speciesId, speciesName, colorId, neopetsImageHash, isSelected, bodyIsCompatible, isValid, couldProbablyModelMoreData, onChange, isLoading, }) => { const selectedBorderColor = useColorModeValue("green.600", "green.400"); const selectedBackgroundColor = useColorModeValue("green.200", "green.600"); const focusBorderColor = "blue.400"; const focusBackgroundColor = "blue.100"; const [ selectedBorderColorValue, selectedBackgroundColorValue, focusBorderColorValue, focusBackgroundColorValue, ] = useToken("colors", [ selectedBorderColor, selectedBackgroundColor, focusBorderColor, focusBackgroundColor, ]); const xlShadow = useToken("shadows", "xl"); const [labelIsHovered, setLabelIsHovered] = React.useState(false); const [inputIsFocused, setInputIsFocused] = React.useState(false); const isDisabled = isLoading || !isValid || !bodyIsCompatible; const isHappy = isLoading || (isValid && bodyIsCompatible); const emotionId = isHappy ? "1" : "2"; const cursor = isLoading ? "wait" : isDisabled ? "not-allowed" : "pointer"; let disabledExplanation = null; if (isLoading) { // If we're still loading, don't try to explain anything yet! } else if (!isValid) { disabledExplanation = "(Can't be this color)"; } else if (!bodyIsCompatible) { disabledExplanation = couldProbablyModelMoreData ? "(Item needs models)" : "(Not compatible)"; } const tooltipLabel = ( <div style={{ textAlign: "center" }}> {speciesName} {disabledExplanation && ( <div style={{ fontStyle: "italic", fontSize: "0.75em" }}> {disabledExplanation} </div> )} </div> ); // NOTE: Because we render quite a few of these, avoiding using Chakra // elements like Box helps with render performance! return ( <ClassNames> {({ css }) => ( <DeferredTooltip label={tooltipLabel} placement="top" gutter={-10} // We track hover and focus state manually for the tooltip, so that // keyboard nav to switch between options causes the tooltip to // follow. (By default, the tooltip appears on the first tab focus, // but not when you _change_ options!) isOpen={labelIsHovered || inputIsFocused} > <label style={{ cursor }} onMouseEnter={() => setLabelIsHovered(true)} onMouseLeave={() => setLabelIsHovered(false)} > <input type="radio" aria-label={speciesName} name="species-faces-picker" value={speciesId} checked={isSelected} // It's possible to get this selected via the SpeciesColorPicker, // even if this would normally be disabled. If so, make this // option enabled, so keyboard users can focus and change it. disabled={isDisabled && !isSelected} onChange={() => onChange({ speciesId, colorId })} onFocus={() => setInputIsFocused(true)} onBlur={() => setInputIsFocused(false)} className={css` /* Copied from Chakra's <VisuallyHidden /> */ border: 0px; clip: rect(0px, 0px, 0px, 0px); height: 1px; width: 1px; margin: -1px; padding: 0px; overflow: hidden; white-space: nowrap; position: absolute; `} /> <div className={css` overflow: hidden; transition: all 0.2s; position: relative; input:checked + & { background: ${selectedBackgroundColorValue}; border-radius: 6px; box-shadow: ${xlShadow}, ${selectedBorderColorValue} 0 0 2px 2px; transform: scale(1.2); z-index: 1; } input:focus + & { background: ${focusBackgroundColorValue}; box-shadow: ${xlShadow}, ${focusBorderColorValue} 0 0 0 3px; } `} > <CrossFadeImage src={`https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/1.png`} srcSet={ `https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/1.png 1x, ` + `https://pets.neopets.com/cp/${neopetsImageHash}/${emotionId}/6.png 2x` } alt={speciesName} width={55} height={55} data-is-loading={isLoading} data-is-disabled={isDisabled} className={css` filter: saturate(90%); opacity: 0.9; transition: all 0.2s; &[data-is-disabled="true"] { filter: saturate(0%); opacity: 0.6; } &[data-is-loading="true"] { animation: 0.8s linear 0s infinite alternate none running pulse; } input:checked + * &[data-body-is-disabled="false"] { opacity: 1; filter: saturate(110%); } input:checked + * &[data-body-is-disabled="true"] { opacity: 0.85; } @keyframes pulse { from { opacity: 0.5; } to { opacity: 1; } } /* Alt text for when the image fails to load! We hide it * while still loading though! */ font-size: 0.75rem; text-align: center; &:-moz-loading { visibility: hidden; } &:-moz-broken { padding: 0.5rem; } `} /> </div> </label> </DeferredTooltip> )} </ClassNames> ); }, ); /** * CrossFadeImage is like <img>, but listens for successful load events, and * fades from the previous image to the new image once it loads. * * We treat `src` as a unique key representing the image's identity, but we * also carry along the rest of the props during the fade, like `srcSet` and * `className`. */ function CrossFadeImage(incomingImageProps) { const [prevImageProps, setPrevImageProps] = React.useState(null); const [currentImageProps, setCurrentImageProps] = React.useState(null); const incomingImageIsCurrentImage = incomingImageProps.src === currentImageProps?.src; const onLoadNextImage = () => { setPrevImageProps(currentImageProps); setCurrentImageProps(incomingImageProps); }; // The main trick to this component is using React's `key` feature! When // diffing the rendered tree, if React sees two nodes with the same `key`, it // treats them as the same node and makes the prop changes to match. // // We usually use this in `.map`, to make sure that adds/removes in a list // don't cause our children to shift around and swap their React state or DOM // nodes with each other. // // But here, we use `key` to get React to transition the same <img> DOM node // between 3 different states! // // The image starts its life as the last in the list, from // `incomingImageProps`: it's invisible, and still loading. We use its `src` // as the `key`. // // When it loads, we update the state so that this `key` now belongs to the // _second_ node, from `currentImageProps`. React will see this and make the // correct transition for us: it sets opacity to 0, sets z-index to 2, // removes aria-hidden, and removes the `onLoad` handler. // // Then, when another image is ready to show, we update the state so that // this key now belongs to the _first_ node, from `prevImageProps` (and the // second node is showing something new). React sees this, and makes the // transition back to invisibility, but without the `onLoad` handler this // time! (And transitions the current image into view, like it did for this // one.) // // Finally, when yet _another_ image is ready to show, we stop rendering any // images with this key anymore, and so React unmounts the image entirely. // // Thanks, React, for handling our multiple overlapping transitions through // this little state machine! This could have been a LOT harder to write, // whew! return ( <ClassNames> {({ css }) => ( <div className={css` display: grid; grid-template-areas: "shared-overlapping-area"; isolation: isolate; /* Avoid z-index conflicts with parent! */ > div { grid-area: shared-overlapping-area; transition: opacity 0.2s; } `} > {prevImageProps && ( <div key={prevImageProps.src} className={css` z-index: 3; opacity: 0; `} > {/* eslint-disable-next-line jsx-a11y/alt-text */} <img {...prevImageProps} aria-hidden /> </div> )} {currentImageProps && ( <div key={currentImageProps.src} className={css` z-index: 2; opacity: 1; `} > {/* eslint-disable-next-line jsx-a11y/alt-text */} <img {...currentImageProps} // If the current image _is_ the incoming image, we'll allow // new props to come in and affect it. But if it's a new image // incoming, we want to stick to the last props the current // image had! (This matters for e.g. `bodyIsCompatible` // becoming true in `SpeciesFaceOption` and restoring color, // before the new color's image loads in.) {...(incomingImageIsCurrentImage ? incomingImageProps : {})} /> </div> )} {!incomingImageIsCurrentImage && ( <div key={incomingImageProps.src} className={css` z-index: 1; opacity: 0; `} > {/* eslint-disable-next-line jsx-a11y/alt-text */} <img {...incomingImageProps} aria-hidden onLoad={onLoadNextImage} /> </div> )} </div> )} </ClassNames> ); } /** * DeferredTooltip is like Chakra's <Tooltip />, but it waits until `isOpen` is * true before mounting it, and unmounts it after closing. * * This can drastically improve render performance when there are lots of * tooltip targets to re-render… but it comes with some limitations, like the * extra requirement to control `isOpen`, and some additional DOM structure! */ function DeferredTooltip({ children, isOpen, ...props }) { const [shouldShowTooltip, setShouldShowToolip] = React.useState(isOpen); React.useEffect(() => { if (isOpen) { setShouldShowToolip(true); } else { const timeoutId = setTimeout(() => setShouldShowToolip(false), 500); return () => clearTimeout(timeoutId); } }, [isOpen]); return ( <ClassNames> {({ css }) => ( <div className={css` position: relative; `} > {children} {shouldShowTooltip && ( <Tooltip isOpen={isOpen} {...props}> <div className={css` position: absolute; top: 0; left: 0; right: 0; bottom: 0; pointer-events: none; `} /> </Tooltip> )} </div> )} </ClassNames> ); } // HACK: I'm just hardcoding all this, rather than connecting up to the // database and adding a loading state. Tbh I'm not sure it's a good idea // to load this dynamically until we have SSR to make it come in fast! // And it's not so bad if this gets out of sync with the database, // because the SpeciesColorPicker will still be usable! const colors = { BLUE: "8", RED: "61", GREEN: "34", YELLOW: "84" }; export function colorIsBasic(colorId) { return ["8", "34", "61", "84"].includes(colorId); } const DEFAULT_SPECIES_FACES = [ { speciesName: "Acara", speciesId: "1", colorId: colors.GREEN, bodyId: "93", neopetsImageHash: "obxdjm88", }, { speciesName: "Aisha", speciesId: "2", colorId: colors.BLUE, bodyId: "106", neopetsImageHash: "n9ozx4z5", }, { speciesName: "Blumaroo", speciesId: "3", colorId: colors.YELLOW, bodyId: "47", neopetsImageHash: "kfonqhdc", }, { speciesName: "Bori", speciesId: "4", colorId: colors.YELLOW, bodyId: "84", neopetsImageHash: "sc2hhvhn", }, { speciesName: "Bruce", speciesId: "5", colorId: colors.YELLOW, bodyId: "146", neopetsImageHash: "wqz8xn4t", }, { speciesName: "Buzz", speciesId: "6", colorId: colors.YELLOW, bodyId: "250", neopetsImageHash: "jc9klfxm", }, { speciesName: "Chia", speciesId: "7", colorId: colors.RED, bodyId: "212", neopetsImageHash: "4lrb4n3f", }, { speciesName: "Chomby", speciesId: "8", colorId: colors.YELLOW, bodyId: "74", neopetsImageHash: "bdml26md", }, { speciesName: "Cybunny", speciesId: "9", colorId: colors.GREEN, bodyId: "94", neopetsImageHash: "xl6msllv", }, { speciesName: "Draik", speciesId: "10", colorId: colors.YELLOW, bodyId: "132", neopetsImageHash: "bob39shq", }, { speciesName: "Elephante", speciesId: "11", colorId: colors.RED, bodyId: "56", neopetsImageHash: "jhhhbrww", }, { speciesName: "Eyrie", speciesId: "12", colorId: colors.RED, bodyId: "90", neopetsImageHash: "6kngmhvs", }, { speciesName: "Flotsam", speciesId: "13", colorId: colors.GREEN, bodyId: "136", neopetsImageHash: "47vt32x2", }, { speciesName: "Gelert", speciesId: "14", colorId: colors.YELLOW, bodyId: "138", neopetsImageHash: "5nrd2lvd", }, { speciesName: "Gnorbu", speciesId: "15", colorId: colors.BLUE, bodyId: "166", neopetsImageHash: "6c275jcg", }, { speciesName: "Grarrl", speciesId: "16", colorId: colors.BLUE, bodyId: "119", neopetsImageHash: "j7q65fv4", }, { speciesName: "Grundo", speciesId: "17", colorId: colors.GREEN, bodyId: "126", neopetsImageHash: "5xn4kjf8", }, { speciesName: "Hissi", speciesId: "18", colorId: colors.RED, bodyId: "67", neopetsImageHash: "jsfvcqwt", }, { speciesName: "Ixi", speciesId: "19", colorId: colors.GREEN, bodyId: "163", neopetsImageHash: "w32r74vo", }, { speciesName: "Jetsam", speciesId: "20", colorId: colors.YELLOW, bodyId: "147", neopetsImageHash: "kz43rnld", }, { speciesName: "Jubjub", speciesId: "21", colorId: colors.GREEN, bodyId: "80", neopetsImageHash: "m267j935", }, { speciesName: "Kacheek", speciesId: "22", colorId: colors.YELLOW, bodyId: "117", neopetsImageHash: "4gsrb59g", }, { speciesName: "Kau", speciesId: "23", colorId: colors.BLUE, bodyId: "201", neopetsImageHash: "ktlxmrtr", }, { speciesName: "Kiko", speciesId: "24", colorId: colors.GREEN, bodyId: "51", neopetsImageHash: "42j5q3zx", }, { speciesName: "Koi", speciesId: "25", colorId: colors.GREEN, bodyId: "208", neopetsImageHash: "ncfn87wk", }, { speciesName: "Korbat", speciesId: "26", colorId: colors.RED, bodyId: "196", neopetsImageHash: "omx9c876", }, { speciesName: "Kougra", speciesId: "27", colorId: colors.BLUE, bodyId: "143", neopetsImageHash: "rfsbh59t", }, { speciesName: "Krawk", speciesId: "28", colorId: colors.BLUE, bodyId: "150", neopetsImageHash: "hxgsm5d4", }, { speciesName: "Kyrii", speciesId: "29", colorId: colors.YELLOW, bodyId: "175", neopetsImageHash: "blxmjgbk", }, { speciesName: "Lenny", speciesId: "30", colorId: colors.YELLOW, bodyId: "173", neopetsImageHash: "8r94jhfq", }, { speciesName: "Lupe", speciesId: "31", colorId: colors.YELLOW, bodyId: "199", neopetsImageHash: "z42535zh", }, { speciesName: "Lutari", speciesId: "32", colorId: colors.BLUE, bodyId: "52", neopetsImageHash: "qgg6z8s7", }, { speciesName: "Meerca", speciesId: "33", colorId: colors.YELLOW, bodyId: "109", neopetsImageHash: "kk2nn2jr", }, { speciesName: "Moehog", speciesId: "34", colorId: colors.GREEN, bodyId: "134", neopetsImageHash: "jgkoro5z", }, { speciesName: "Mynci", speciesId: "35", colorId: colors.BLUE, bodyId: "95", neopetsImageHash: "xwlo9657", }, { speciesName: "Nimmo", speciesId: "36", colorId: colors.BLUE, bodyId: "96", neopetsImageHash: "bx7fho8x", }, { speciesName: "Ogrin", speciesId: "37", colorId: colors.YELLOW, bodyId: "154", neopetsImageHash: "rjzmx24v", }, { speciesName: "Peophin", speciesId: "38", colorId: colors.RED, bodyId: "55", neopetsImageHash: "kokc52kh", }, { speciesName: "Poogle", speciesId: "39", colorId: colors.GREEN, bodyId: "76", neopetsImageHash: "fw6lvf3c", }, { speciesName: "Pteri", speciesId: "40", colorId: colors.RED, bodyId: "156", neopetsImageHash: "tjhwbro3", }, { speciesName: "Quiggle", speciesId: "41", colorId: colors.YELLOW, bodyId: "78", neopetsImageHash: "jdto7mj4", }, { speciesName: "Ruki", speciesId: "42", colorId: colors.BLUE, bodyId: "191", neopetsImageHash: "qsgbm5f6", }, { speciesName: "Scorchio", speciesId: "43", colorId: colors.RED, bodyId: "187", neopetsImageHash: "hkjoncsx", }, { speciesName: "Shoyru", speciesId: "44", colorId: colors.YELLOW, bodyId: "46", neopetsImageHash: "mmvn4tkg", }, { speciesName: "Skeith", speciesId: "45", colorId: colors.RED, bodyId: "178", neopetsImageHash: "fc4cxk3t", }, { speciesName: "Techo", speciesId: "46", colorId: colors.YELLOW, bodyId: "100", neopetsImageHash: "84gvowmj", }, { speciesName: "Tonu", speciesId: "47", colorId: colors.BLUE, bodyId: "130", neopetsImageHash: "jd433863", }, { speciesName: "Tuskaninny", speciesId: "48", colorId: colors.YELLOW, bodyId: "188", neopetsImageHash: "q39wn6vq", }, { speciesName: "Uni", speciesId: "49", colorId: colors.GREEN, bodyId: "257", neopetsImageHash: "njzvoflw", }, { speciesName: "Usul", speciesId: "50", colorId: colors.RED, bodyId: "206", neopetsImageHash: "rox4mgh5", }, { speciesName: "Vandagyre", speciesId: "55", colorId: colors.YELLOW, bodyId: "306", neopetsImageHash: "xkntzsww", }, { speciesName: "Wocky", speciesId: "51", colorId: colors.YELLOW, bodyId: "101", neopetsImageHash: "dnr2kj4b", }, { speciesName: "Xweetok", speciesId: "52", colorId: colors.RED, bodyId: "68", neopetsImageHash: "tdkqr2b6", }, { speciesName: "Yurble", speciesId: "53", colorId: colors.RED, bodyId: "182", neopetsImageHash: "h95cs547", }, { speciesName: "Zafara", speciesId: "54", colorId: colors.BLUE, bodyId: "180", neopetsImageHash: "x8c57g2l", }, ]; export default SpeciesFacesPicker;