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 { css } from "emotion";
import { CSSTransition, TransitionGroup } from "react-transition-group";
import {
Box,
Flex,
IconButton,
Image,
PseudoBox,
Skeleton,
Tooltip,
useTheme,
@ -14,33 +12,6 @@ import {
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 }) {
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 isInOutfit = allItemIds.includes(item.id);
const theme = useTheme();
@ -64,7 +35,7 @@ export function Item({ item, outfitState, dispatchToOutfit }) {
<ItemContainer>
<ItemThumbnail src={item.thumbnailUrl} />
<Box width="3" />
<ItemName>{item.name}</ItemName>
<ItemName id={itemNameId}>{item.name}</ItemName>
<Box flexGrow="1" />
{isInOutfit && (
<Tooltip label="Remove" placement="top">
@ -175,7 +146,7 @@ function ItemThumbnail({ src }) {
);
}
function ItemName({ children }) {
function ItemName({ children, ...props }) {
const theme = useTheme();
return (
@ -194,10 +165,9 @@ function ItemName({ children }) {
font-weight: ${theme.fontWeights.bold};
}
`}
{...props}
>
{children}
</Box>
);
}
export default ItemList;

View file

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

View file

@ -1,17 +1,18 @@
import React from "react";
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 { Delay, Heading1, useDebounce } from "./util";
import ItemList, { ItemListSkeleton } from "./ItemList";
import { ItemListContainer, ItemListSkeleton, Item } from "./ItemList";
import { itemAppearanceFragment } from "./OutfitPreview";
function SearchPanel({
query,
outfitState,
dispatchToOutfit,
getScrollParent,
firstSearchResultRef,
onMoveFocusUpToQuery,
}) {
return (
<Box color="green.800">
@ -20,13 +21,20 @@ function SearchPanel({
query={query}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
getScrollParent={getScrollParent}
firstSearchResultRef={firstSearchResultRef}
onMoveFocusUpToQuery={onMoveFocusUpToQuery}
/>
</Box>
);
}
function SearchResults({ query, outfitState, dispatchToOutfit }) {
function SearchResults({
query,
outfitState,
dispatchToOutfit,
firstSearchResultRef,
onMoveFocusUpToQuery,
}) {
const { speciesId, colorId } = outfitState;
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 (
<ScrollTracker threshold={300} onScrolledToBottom={onScrolledToBottom}>
<ItemList
items={items}
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
<ItemListContainer>
{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}
dispatchToOutfit={dispatchToOutfit}
/>
</label>
))}
</ItemListContainer>
{items && loading && <ItemListSkeleton count={8} />}
</ScrollTracker>
);
}
function ScrollTracker({ children, query, threshold, onScrolledToBottom }) {
function ScrollTracker({ children, threshold, onScrolledToBottom }) {
const containerRef = React.useRef();
const scrollParent = React.useRef();
@ -209,7 +272,7 @@ function ScrollTracker({ children, query, threshold, onScrolledToBottom }) {
};
}, [onScroll]);
return <Box ref={containerRef}>{children}</Box>;
return <div ref={containerRef}>{children}</div>;
}
export default SearchPanel;

View file

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