Skip to content

Add autocomplete #529

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
Draft
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
@@ -5,6 +5,7 @@
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,109 +16,152 @@

const defaultEstimateSize = () => 28

const indexCalculatorByKey = {
ArrowDown: (index, length) => Math.min(index + 1, length - 1),

Check warning on line 20 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
ArrowUp: index => Math.max(index - 1, 0),

Check warning on line 21 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
Home: () => 0,

Check warning on line 22 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
End: (_, length) => length - 1,

Check warning on line 23 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
default: index => index,

Check warning on line 24 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 25 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

const getNextIndex = (currentIndex, key, itemsLength) => {
const calculator = indexCalculatorByKey[key] || indexCalculatorByKey.default

Check warning on line 28 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 28 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

Check warning on line 28 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
return calculator(currentIndex, itemsLength)

Check warning on line 29 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 30 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

const Dropdown = forwardRef(
(
{
hideShadow,
itemProps,
items,
onItemClick,
dropTitle,
dropTitlePadding = [3, 3, 0],
Item,
Footer,
value,
hasSearch,
searchMargin = [4],
gap = 0,
estimateSize = defaultEstimateSize,
close,
enableKeyNavigation,
activeIndex,
setActiveIndex,
...rest
},
forwardedRef
) => {
const [searchValue, setSearchValue] = useState("")

const filteredItems = useMemo(() => {
if (!searchValue) return items

const searchLowerCase = searchValue.toLowerCase()

return items.filter(({ label, value: val }) => {
if (typeof label === "string" && label.toLowerCase().includes(searchLowerCase)) return true
if (typeof val === "string" && val.toLowerCase().includes(searchLowerCase)) return true
return false
})
}, [items, searchValue])

const ref = useRef()

const rowVirtualizer = useVirtualizer({
count: filteredItems.length,
getScrollElement: () => ref.current,
scrollOffsetFn: event => (event ? event.target.scrollTop - ref.current.offsetTop : 0),
overscan: 3,
enableSmoothScroll: false,
estimateSize,
})

const handleKeyDown = useCallback(
event => {
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.code)) {
setActiveIndex(prevIndex => {
const nextIndex = getNextIndex(prevIndex, event.code, items.length)

Check warning on line 85 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
rowVirtualizer.scrollToIndex(nextIndex)

Check warning on line 86 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
return nextIndex

Check warning on line 87 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
})

Check warning on line 88 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 89 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 89 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
},
[rowVirtualizer, items, setActiveIndex]
)

Check warning on line 92 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

const virtualContainerProps = useMemo(() => {
if (enableKeyNavigation)
return {
tabIndex: 0,
role: "listbox",
"aria-activedescendant": `item-${activeIndex}`,
onKeyDown: handleKeyDown,
}

Check warning on line 101 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 101 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 101 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

return {}

Check warning on line 103 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}, [enableKeyNavigation, activeIndex, handleKeyDown])

Check warning on line 104 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

return (
<Container
as="ul"
role="listbox"
background="dropdown"
hideShadow={hideShadow}
padding={[0]}
margin={[1, 0]}
column
tabindex="-1"
width="auto"
{...rest}
>
{dropTitle && <Flex padding={dropTitlePadding}>{dropTitle}</Flex>}
{hasSearch && (
<Box margin={searchMargin}>
<Search data-testid="dropdown-search" placeholder="Search" onChange={setSearchValue} />
</Box>
)}
<div
ref={mergeRefs(ref, forwardedRef)}
style={{
height: "100%",
overflow: "auto",
}}
{...virtualContainerProps}
>
<div
style={{
minHeight: `${rowVirtualizer.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${virtualRow.start}px)`,
padding: gap * 2,
overflow: "hidden",
}}
data-index={virtualRow.index}
ref={rowVirtualizer.measureElement}
>
<Item
item={filteredItems[virtualRow.index]}
index={virtualRow.index}
itemProps={itemProps}
value={value}
onItemClick={onItemClick}
close={close}
{...(enableKeyNavigation ? { enableKeyNavigation: true, activeIndex } : {})}
/>
</div>

Check warning on line 164 in src/components/drops/menu/dropdown.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
))}
</div>
</div>
6 changes: 6 additions & 0 deletions src/components/drops/menu/dropdownItem.js
Original file line number Diff line number Diff line change
@@ -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 (
<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}
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
@@ -13,64 +15,110 @@
)
}

export const TextInput = ({
error,
disabled,
iconLeft,
iconRight,
name,
onFocus,
onBlur,
className,
hint,
fieldIndicator,
placeholder = "",
label,
value,
inputRef,
size = "large",
containerStyles,
inputContainerStyles,
hideErrorMessage,
autocompleteProps,
...props
}) => {
const ref = useRef()

Check warning on line 40 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
const autocompleteMenuRef = useRef()

Check warning on line 41 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

const onKeyDown = useCallback(
e => {

Check warning on line 44 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
if (autocompleteMenuRef.current && ["ArrowDown", "ArrowUp"].includes(e.key)) {

Check warning on line 45 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch

Check warning on line 45 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
autocompleteMenuRef.current.focus()

Check warning on line 46 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 47 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 47 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
},
[autocompleteMenuRef?.current]
)

Check warning on line 50 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

const onAutocompleteEscape = useCallback(() => {

Check warning on line 52 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
if (ref?.current) {
ref.current.focus()

Check warning on line 54 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 55 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 55 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
}, [ref])

Check warning on line 56 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

const autocompleteInputProps = useMemo(
() =>
autocompleteProps
? {
"aria-autocomplete": "list",
"aria-controls": "autocomplete-list",
onKeyDown,
}

Check warning on line 65 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🌿 Branch is not covered

Warning! Not covered branch
: {},

Check warning on line 66 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
[autocompleteProps, onKeyDown]
)

Check warning on line 68 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

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 && (
<Flex position="absolute" left={1} top={0} bottom={0} alignItems="center">
{iconLeft}
</Flex>
)}
<Input
disabled={disabled}
placeholder={placeholder}
onBlur={onBlur}
onFocus={onFocus}
name={name}
aria-label={name}
hasIconLeft={!!iconLeft}
hasIconRight={!!iconRight}
hasIndicator={!!fieldIndicator}
type="text"
value={value}
size={size}
ref={inputRef}
ref={mergeRefs(inputRef, ref)}
error={error}
hasValue={!!value}
{...autocompleteInputProps}
{...props}
/>

{(!!iconRight || !!fieldIndicator) && (
<Flex position="absolute" right={1} top={0} bottom={0} alignItems="center" gap={1}>
{!!fieldIndicator && <TextMicro color="textLite">{fieldIndicator}</TextMicro>}
{!!iconRight && iconRight}
</Flex>
)}
</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>
)

Check warning on line 123 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
}

Check warning on line 124 in src/components/input/input.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement
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
@@ -12,6 +13,23 @@

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}`),

Check warning on line 21 in src/components/input/input.stories.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

Check warning on line 21 in src/components/input/input.stories.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🕹️ Function is not covered

Warning! Not covered function
error: null,
},
}

Check warning on line 24 in src/components/input/input.stories.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

const onChange = e => {
setValue(e.target.value)
}

return <TextInput value={value} onChange={onChange} autocompleteProps={autocompleteProps} />
}

Check warning on line 31 in src/components/input/input.stories.js

GitHub Actions / Coverage annotations (🧪 jest-coverage-report-action)

🧾 Statement is not covered

Warning! Not covered statement

export default {
component: TextInput,
args: {