Skip to content
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

Add autocomplete #529

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from
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 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,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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

🧾 Statement is not covered

Warning! Not covered statement
))}
</div>
</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 All @@ -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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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
Expand All @@ -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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

View workflow job for this annotation

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

🧾 Statement is not covered

Warning! Not covered statement

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