Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/components/drops/menu/dropdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) =>
Expand All @@ -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(
(
{
Expand All @@ -32,6 +46,9 @@ const Dropdown = forwardRef(
gap = 0,
estimateSize = defaultEstimateSize,
close,
enableKeyNavigation,
activeIndex,
setActiveIndex,
...rest
},
forwardedRef
Expand Down Expand Up @@ -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 (
<Container
as="ul"
Expand All @@ -86,6 +128,7 @@ const Dropdown = forwardRef(
height: "100%",
overflow: "auto",
}}
{...virtualContainerProps}
>
<div
style={{
Expand Down Expand Up @@ -116,6 +159,7 @@ const Dropdown = forwardRef(
value={value}
onItemClick={onItemClick}
close={close}
{...(enableKeyNavigation ? { enableKeyNavigation: true, activeIndex } : {})}
/>
</div>
))}
Expand Down
6 changes: 6 additions & 0 deletions src/components/drops/menu/dropdownItem.js
Original file line number Diff line number Diff line change
Expand Up @@ -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)};
Expand Down Expand Up @@ -43,6 +45,7 @@ const DropdownItem = ({
onItemClick,
index,
style,
enableKeyNavigation,
...rest
}) => {
const selected = selectedValue === value
Expand All @@ -54,11 +57,14 @@ const DropdownItem = ({

return (
<ItemContainer
id={`item-${index}`}
data-index={index}
aria-selected={selected}
disabled={disabled}
selected={selected}
onClick={onSelect}
index={index}
{...(enableKeyNavigation ? { role: "option" } : {})}
{...restItem}
{...rest}
style={style}
Expand Down
50 changes: 50 additions & 0 deletions src/components/input/autocomplete/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import React, { forwardRef, useCallback, useState } from "react"
import { StyledOptionsContainer } from "./styled"
import Dropdown from "@/components/drops/menu/dropdown"
import DropdownItem from "@/components/drops/menu/dropdownItem"
import useOutsideClick from "@/hooks/useOutsideClick"
import useAutocomplete from "./useAutocomplete"

const Autocomplete = forwardRef(({ value, autocompleteProps, onInputChange, onEsc }, ref) => {
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 && (
<StyledOptionsContainer>
<Dropdown
ref={ref}
items={filteredSuggestions}
Item={DropdownItem}
onItemClick={onItemClick}
width="100%"
onKeyDown={onKeyDown}
enableKeyNavigation
activeIndex={activeIndex}
setActiveIndex={setActiveIndex}
/>
</StyledOptionsContainer>
)
)
})

export default Autocomplete
10 changes: 10 additions & 0 deletions src/components/input/autocomplete/styled.js
Original file line number Diff line number Diff line change
@@ -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;
`
50 changes: 50 additions & 0 deletions src/components/input/autocomplete/useAutocomplete.js
Original file line number Diff line number Diff line change
@@ -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
54 changes: 51 additions & 3 deletions src/components/input/input.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 (
<Flex gap={0.5} column className={className} {...containerStyles} as="label">
<Flex
gap={0.5}
column
className={className}
position="relative"
{...containerStyles}
as="label"
>
{typeof label === "string" ? <LabelText size={size}>{label}</LabelText> : label}
<Flex position="relative" {...inputContainerStyles}>
{iconLeft && (
Expand All @@ -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}
/>

Expand All @@ -71,6 +112,13 @@ export const TextInput = ({
</Flex>
{typeof hint === "string" ? <TextMicro color="textLite">{hint}</TextMicro> : !!hint && hint}
{!hideErrorMessage ? <Error error={error} /> : null}
<Autocomplete
ref={autocompleteMenuRef}
value={value}
onEsc={onAutocompleteEscape}
autocompleteProps={autocompleteProps}
onInputChange={props.onChange}
/>
</Flex>
)
}
18 changes: 18 additions & 0 deletions src/components/input/input.stories.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import React from "react"
import { Icon } from "@/components/icon"
import { TextInput } from "."
import { useState } from "react"

export const WithIcons = args => (
<TextInput
Expand All @@ -12,6 +13,23 @@ export const WithIcons = args => (

export const Basic = args => <TextInput {...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 <TextInput value={value} onChange={onChange} autocompleteProps={autocompleteProps} />
}

export default {
component: TextInput,
args: {
Expand Down