checkboxes and keyboard nav for search view!

This commit is contained in:
Matt Dunn-Rankin 2020-04-25 20:06:51 -07:00
parent 40794d4e71
commit 8866b6bca6
4 changed files with 118 additions and 50 deletions

View file

@ -1,12 +1,10 @@
import React from "react"; import React from "react";
import { css } from "emotion"; import { css } from "emotion";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import { import {
Box, Box,
Flex, Flex,
IconButton, IconButton,
Image, Image,
PseudoBox,
Skeleton, Skeleton,
Tooltip, Tooltip,
useTheme, useTheme,
@ -14,33 +12,6 @@ import {
import "./ItemList.css"; import "./ItemList.css";
function ItemList({ items, outfitState, dispatchToOutfit }) {
return (
<Flex direction="column">
<TransitionGroup component={null}>
{items.map((item) => (
<CSSTransition
key={item.id}
classNames="item-list-row"
timeout={500}
onExit={(e) => {
e.style.height = e.offsetHeight + "px";
}}
>
<PseudoBox mb="2" mt="2">
<Item
item={item}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</PseudoBox>
</CSSTransition>
))}
</TransitionGroup>
</Flex>
);
}
export function ItemListContainer({ children }) { export function ItemListContainer({ children }) {
return <Flex direction="column">{children}</Flex>; return <Flex direction="column">{children}</Flex>;
} }
@ -55,7 +26,7 @@ export function ItemListSkeleton({ count }) {
); );
} }
export function Item({ item, outfitState, dispatchToOutfit }) { export function Item({ item, itemNameId, outfitState, dispatchToOutfit }) {
const { allItemIds } = outfitState; const { allItemIds } = outfitState;
const isInOutfit = allItemIds.includes(item.id); const isInOutfit = allItemIds.includes(item.id);
const theme = useTheme(); const theme = useTheme();
@ -64,7 +35,7 @@ export function Item({ item, outfitState, dispatchToOutfit }) {
<ItemContainer> <ItemContainer>
<ItemThumbnail src={item.thumbnailUrl} /> <ItemThumbnail src={item.thumbnailUrl} />
<Box width="3" /> <Box width="3" />
<ItemName>{item.name}</ItemName> <ItemName id={itemNameId}>{item.name}</ItemName>
<Box flexGrow="1" /> <Box flexGrow="1" />
{isInOutfit && ( {isInOutfit && (
<Tooltip label="Remove" placement="top"> <Tooltip label="Remove" placement="top">
@ -175,7 +146,7 @@ function ItemThumbnail({ src }) {
); );
} }
function ItemName({ children }) { function ItemName({ children, ...props }) {
const theme = useTheme(); const theme = useTheme();
return ( return (
@ -194,10 +165,9 @@ function ItemName({ children }) {
font-weight: ${theme.fontWeights.bold}; font-weight: ${theme.fontWeights.bold};
} }
`} `}
{...props}
> >
{children} {children}
</Box> </Box>
); );
} }
export default ItemList;

View file

@ -21,7 +21,7 @@ function ItemsPanel({ outfitState, loading, dispatchToOutfit }) {
const { zonesAndItems } = outfitState; const { zonesAndItems } = outfitState;
return ( return (
<Box color="green.800" position="relative"> <Box color="green.800">
<Box px="1"> <Box px="1">
<OutfitHeading <OutfitHeading
outfitState={outfitState} outfitState={outfitState}
@ -107,6 +107,7 @@ function ItemRadioList({ name, items, outfitState, dispatchToOutfit }) {
<VisuallyHidden <VisuallyHidden
as="input" as="input"
type="radio" type="radio"
aria-labelledby={`${name}-item-${item.id}-name`}
name={name} name={name}
value={item.id} value={item.id}
checked={outfitState.wornItemIds.includes(item.id)} checked={outfitState.wornItemIds.includes(item.id)}
@ -120,6 +121,7 @@ function ItemRadioList({ name, items, outfitState, dispatchToOutfit }) {
/> />
<Item <Item
item={item} item={item}
itemNameId={`${name}-item-${item.id}-name`}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />

View file

@ -1,17 +1,18 @@
import React from "react"; import React from "react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { Box, Text } from "@chakra-ui/core"; import { Box, Text, VisuallyHidden } from "@chakra-ui/core";
import { useQuery } from "@apollo/react-hooks"; import { useQuery } from "@apollo/react-hooks";
import { Delay, Heading1, useDebounce } from "./util"; import { Delay, Heading1, useDebounce } from "./util";
import ItemList, { ItemListSkeleton } from "./ItemList"; import { ItemListContainer, ItemListSkeleton, Item } from "./ItemList";
import { itemAppearanceFragment } from "./OutfitPreview"; import { itemAppearanceFragment } from "./OutfitPreview";
function SearchPanel({ function SearchPanel({
query, query,
outfitState, outfitState,
dispatchToOutfit, dispatchToOutfit,
getScrollParent, firstSearchResultRef,
onMoveFocusUpToQuery,
}) { }) {
return ( return (
<Box color="green.800"> <Box color="green.800">
@ -20,13 +21,20 @@ function SearchPanel({
query={query} query={query}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
getScrollParent={getScrollParent} firstSearchResultRef={firstSearchResultRef}
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
/> />
</Box> </Box>
); );
} }
function SearchResults({ query, outfitState, dispatchToOutfit }) { function SearchResults({
query,
outfitState,
dispatchToOutfit,
firstSearchResultRef,
onMoveFocusUpToQuery,
}) {
const { speciesId, colorId } = outfitState; const { speciesId, colorId } = outfitState;
const debouncedQuery = useDebounce(query, 300, { waitForFirstPause: true }); const debouncedQuery = useDebounce(query, 300, { waitForFirstPause: true });
@ -159,19 +167,74 @@ function SearchResults({ query, outfitState, dispatchToOutfit }) {
); );
} }
const onChange = (e) => {
const itemId = e.target.value;
const willBeWorn = e.target.checked;
if (willBeWorn) {
dispatchToOutfit({ type: "wearItem", itemId });
} else {
dispatchToOutfit({ type: "unwearItem", itemId });
}
};
const goToPrevItem = (e) => {
const prevLabel = e.target.closest("label").previousSibling;
if (prevLabel) {
prevLabel.querySelector("input[type=checkbox]").focus();
e.preventDefault();
} else {
// If we're at the top of the list, move back up to the search box!
onMoveFocusUpToQuery(e);
}
};
const goToNextItem = (e) => {
const nextLabel = e.target.closest("label").nextSibling;
if (nextLabel) {
nextLabel.querySelector("input[type=checkbox]").focus();
e.preventDefault();
}
};
console.log(firstSearchResultRef);
return ( return (
<ScrollTracker threshold={300} onScrolledToBottom={onScrolledToBottom}> <ScrollTracker threshold={300} onScrolledToBottom={onScrolledToBottom}>
<ItemList <ItemListContainer>
items={items} {items.map((item, index) => (
<label key={item.id}>
<VisuallyHidden
as="input"
type="checkbox"
aria-label={`Wear "${item.name}"`}
value={item.id}
checked={outfitState.wornItemIds.includes(item.id)}
ref={index === 0 ? firstSearchResultRef : null}
onChange={onChange}
onKeyDown={(e) => {
console.log(e.key);
if (e.key === "ArrowUp") {
goToPrevItem(e);
} else if (e.key === "ArrowDown") {
goToNextItem(e);
} else if (e.key === "Escape") {
onMoveFocusUpToQuery(e);
}
}}
/>
<Item
item={item}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
/> />
</label>
))}
</ItemListContainer>
{items && loading && <ItemListSkeleton count={8} />} {items && loading && <ItemListSkeleton count={8} />}
</ScrollTracker> </ScrollTracker>
); );
} }
function ScrollTracker({ children, query, threshold, onScrolledToBottom }) { function ScrollTracker({ children, threshold, onScrolledToBottom }) {
const containerRef = React.useRef(); const containerRef = React.useRef();
const scrollParent = React.useRef(); const scrollParent = React.useRef();
@ -209,7 +272,7 @@ function ScrollTracker({ children, query, threshold, onScrolledToBottom }) {
}; };
}, [onScroll]); }, [onScroll]);
return <Box ref={containerRef}>{children}</Box>; return <div ref={containerRef}>{children}</div>;
} }
export default SearchPanel; export default SearchPanel;

View file

@ -21,6 +21,8 @@ function WardrobePage() {
const [searchQuery, setSearchQuery] = React.useState(""); const [searchQuery, setSearchQuery] = React.useState("");
const toast = useToast(); const toast = useToast();
const searchContainerRef = React.useRef(); const searchContainerRef = React.useRef();
const searchQueryRef = React.useRef();
const firstSearchResultRef = React.useRef();
React.useEffect(() => { React.useEffect(() => {
if (error) { if (error) {
@ -72,13 +74,24 @@ function WardrobePage() {
</Box> </Box>
<Box gridArea="search" boxShadow="sm"> <Box gridArea="search" boxShadow="sm">
<Box px="5" py="3"> <Box px="5" py="3">
<SearchToolbar query={searchQuery} onChange={setSearchQuery} /> <SearchToolbar
query={searchQuery}
queryRef={searchQueryRef}
onChange={setSearchQuery}
onMoveFocusDownToResults={(e) => {
if (firstSearchResultRef.current) {
firstSearchResultRef.current.focus();
e.preventDefault();
}
}}
/>
</Box> </Box>
</Box> </Box>
{searchQuery ? ( {searchQuery ? (
<Box <Box
gridArea="items" gridArea="items"
position="relative"
overflow="auto" overflow="auto"
key="search-panel" key="search-panel"
ref={searchContainerRef} ref={searchContainerRef}
@ -88,11 +101,23 @@ function WardrobePage() {
query={searchQuery} query={searchQuery}
outfitState={outfitState} outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit} dispatchToOutfit={dispatchToOutfit}
firstSearchResultRef={firstSearchResultRef}
onMoveFocusUpToQuery={(e) => {
if (searchQueryRef.current) {
searchQueryRef.current.focus();
e.preventDefault();
}
}}
/> />
</Box> </Box>
</Box> </Box>
) : ( ) : (
<Box gridArea="items" overflow="auto" key="items-panel"> <Box
gridArea="items"
position="relative"
overflow="auto"
key="items-panel"
>
<Box px="5" py="5"> <Box px="5" py="5">
<ItemsPanel <ItemsPanel
loading={loading} loading={loading}
@ -107,7 +132,12 @@ function WardrobePage() {
); );
} }
function SearchToolbar({ query, onChange }) { function SearchToolbar({
query,
queryRef,
onChange,
onMoveFocusDownToResults,
}) {
return ( return (
<InputGroup> <InputGroup>
<InputLeftElement> <InputLeftElement>
@ -118,11 +148,14 @@ function SearchToolbar({ query, onChange }) {
focusBorderColor="green.600" focusBorderColor="green.600"
color="green.800" color="green.800"
value={query} value={query}
ref={queryRef}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Escape") { if (e.key === "Escape") {
onChange(""); onChange("");
e.target.blur(); e.target.blur();
} else if (e.key === "ArrowDown") {
onMoveFocusDownToResults(e);
} }
}} }}
/> />