diff --git a/src/components/drops/menu/dropdown.js b/src/components/drops/menu/dropdown.js index 1886c7ee..11e890c1 100644 --- a/src/components/drops/menu/dropdown.js +++ b/src/components/drops/menu/dropdown.js @@ -5,6 +5,7 @@ import Flex from "@/components/templates/flex" import Search from "@/components/search" import Box from "@/components/templates/box" import { mergeRefs } from "@/utils" +import { useCallback } from "react" const Container = styled(Flex)` ${({ hideShadow }) => @@ -15,6 +16,19 @@ const Container = styled(Flex)` const defaultEstimateSize = () => 28 +const indexCalculatorByKey = { + ArrowDown: (index, length) => Math.min(index + 1, length - 1), + ArrowUp: index => Math.max(index - 1, 0), + Home: () => 0, + End: (_, length) => length - 1, + default: index => index, +} + +const getNextIndex = (currentIndex, key, itemsLength) => { + const calculator = indexCalculatorByKey[key] || indexCalculatorByKey.default + return calculator(currentIndex, itemsLength) +} + const Dropdown = forwardRef( ( { @@ -32,6 +46,9 @@ const Dropdown = forwardRef( gap = 0, estimateSize = defaultEstimateSize, close, + enableKeyNavigation, + activeIndex, + setActiveIndex, ...rest }, forwardedRef @@ -61,6 +78,31 @@ const Dropdown = forwardRef( estimateSize, }) + const handleKeyDown = useCallback( + event => { + if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.code)) { + setActiveIndex(prevIndex => { + const nextIndex = getNextIndex(prevIndex, event.code, items.length) + rowVirtualizer.scrollToIndex(nextIndex) + return nextIndex + }) + } + }, + [rowVirtualizer, items, setActiveIndex] + ) + + const virtualContainerProps = useMemo(() => { + if (enableKeyNavigation) + return { + tabIndex: 0, + role: "listbox", + "aria-activedescendant": `item-${activeIndex}`, + onKeyDown: handleKeyDown, + } + + return {} + }, [enableKeyNavigation, activeIndex, handleKeyDown]) + return (
))} diff --git a/src/components/drops/menu/dropdownItem.js b/src/components/drops/menu/dropdownItem.js index 4aae3bb8..8ba2b1ef 100644 --- a/src/components/drops/menu/dropdownItem.js +++ b/src/components/drops/menu/dropdownItem.js @@ -13,6 +13,8 @@ export const ItemContainer = styled(Flex).attrs(props => ({ cursor: ${({ cursor }) => cursor ?? "pointer"}; opacity: ${({ disabled, selected }) => (selected ? 0.9 : disabled ? 0.4 : 1)}; pointer-events: ${({ disabled }) => (disabled ? "none" : "auto")}; + background-color: ${props => + props.activeIndex == props.index ? getColor("borderSecondary")(props) : "none"}; &:hover { background-color: ${props => getColor("borderSecondary")(props)}; @@ -43,6 +45,7 @@ const DropdownItem = ({ onItemClick, index, style, + enableKeyNavigation, ...rest }) => { const selected = selectedValue === value @@ -54,11 +57,14 @@ const DropdownItem = ({ return ( { + const [activeIndex, setActiveIndex] = useState(0) + const { autocompleteOpen, close, filteredSuggestions, onItemClick } = useAutocomplete({ + value, + onInputChange, + autocompleteProps, + }) + + const onKeyDown = useCallback( + e => { + if (e.code == "Escape") { + onEsc() + close() + } else if (e.code == "Enter") { + onItemClick(filteredSuggestions[activeIndex]?.value) + onEsc() + } + }, + [activeIndex, filteredSuggestions, onItemClick, onEsc, close] + ) + + useOutsideClick(ref, close, ref?.current) + + return ( + autocompleteOpen && ( + + + + ) + ) +}) + +export default Autocomplete diff --git a/src/components/input/autocomplete/styled.js b/src/components/input/autocomplete/styled.js new file mode 100644 index 00000000..5b2096b2 --- /dev/null +++ b/src/components/input/autocomplete/styled.js @@ -0,0 +1,10 @@ +import styled from "styled-components" +import Flex from "@/components/templates/flex" + +export const StyledOptionsContainer = styled(Flex)` + width: 300px; + max-height: 300px; + position: absolute; + left: 0; + top: 36px; +` diff --git a/src/components/input/autocomplete/useAutocomplete.js b/src/components/input/autocomplete/useAutocomplete.js new file mode 100644 index 00000000..6e61bad7 --- /dev/null +++ b/src/components/input/autocomplete/useAutocomplete.js @@ -0,0 +1,50 @@ +import { useState, useEffect, useMemo, useCallback } from "react" + +const defaultSuggestions = { + loading: false, + loaded: true, + value: [], + error: null, +} + +const useAutocomplete = ({ value, onInputChange, autocompleteProps = {} }) => { + const [autocompleteOpen, setAutocompleteOpen] = useState() + const { suggestions = defaultSuggestions } = autocompleteProps || {} + const items = useMemo( + () => + suggestions.value.map(suggestion => ({ + value: suggestion, + label: suggestion, + })), + [suggestions] + ) + const [filteredSuggestions, setFilteredSuggestions] = useState(items) + + const close = useCallback(() => setAutocompleteOpen(false), [setAutocompleteOpen]) + + const onItemClick = useCallback( + val => { + if (typeof onInputChange == "function") { + onInputChange({ target: { value: val } }) + setTimeout(() => close(), 100) + } + }, + [close, onInputChange] + ) + + useEffect(() => { + if (!value) { + close() + } else if (items.length) { + const filtered = items.filter(({ label }) => + label.toLowerCase().includes(value.toLowerCase()) + ) + setFilteredSuggestions(filtered) + setAutocompleteOpen(!!filtered.length) + } + }, [value, items, setAutocompleteOpen, setFilteredSuggestions, close]) + + return { autocompleteOpen, close, filteredSuggestions, onItemClick } +} + +export default useAutocomplete diff --git a/src/components/input/input.js b/src/components/input/input.js index b0ea557e..9b979236 100644 --- a/src/components/input/input.js +++ b/src/components/input/input.js @@ -1,7 +1,9 @@ -import React from "react" +import React, { useMemo, useRef, useCallback } from "react" import Flex from "@/components/templates/flex" import { TextMicro } from "@/components/typography" import { Input, LabelText } from "./styled" +import Autocomplete from "./autocomplete" +import { mergeRefs } from "@/utils" const Error = ({ error }) => { const errorMessage = error === true ? "invalid" : error @@ -32,10 +34,48 @@ export const TextInput = ({ containerStyles, inputContainerStyles, hideErrorMessage, + autocompleteProps, ...props }) => { + const ref = useRef() + const autocompleteMenuRef = useRef() + + const onKeyDown = useCallback( + e => { + if (autocompleteMenuRef.current && ["ArrowDown", "ArrowUp"].includes(e.key)) { + autocompleteMenuRef.current.focus() + } + }, + [autocompleteMenuRef?.current] + ) + + const onAutocompleteEscape = useCallback(() => { + if (ref?.current) { + ref.current.focus() + } + }, [ref]) + + const autocompleteInputProps = useMemo( + () => + autocompleteProps + ? { + "aria-autocomplete": "list", + "aria-controls": "autocomplete-list", + onKeyDown, + } + : {}, + [autocompleteProps, onKeyDown] + ) + return ( - + {typeof label === "string" ? {label} : label} {iconLeft && ( @@ -56,9 +96,10 @@ export const TextInput = ({ type="text" value={value} size={size} - ref={inputRef} + ref={mergeRefs(inputRef, ref)} error={error} hasValue={!!value} + {...autocompleteInputProps} {...props} /> @@ -71,6 +112,13 @@ export const TextInput = ({ {typeof hint === "string" ? {hint} : !!hint && hint} {!hideErrorMessage ? : null} + ) } diff --git a/src/components/input/input.stories.js b/src/components/input/input.stories.js index e917fd82..dd969b9a 100644 --- a/src/components/input/input.stories.js +++ b/src/components/input/input.stories.js @@ -1,6 +1,7 @@ import React from "react" import { Icon } from "@/components/icon" import { TextInput } from "." +import { useState } from "react" export const WithIcons = args => ( ( export const Basic = args => +export const WithAutocomplete = () => { + const [value, setValue] = useState("") + const autocompleteProps = { + suggestions: { + loading: false, + value: Array.from(Array(10000).keys()).map(i => `Label ${i}`), + error: null, + }, + } + + const onChange = e => { + setValue(e.target.value) + } + + return +} + export default { component: TextInput, args: {