Skip to content

Commit a23e911

Browse files
committed
Add dropdown key navigation
1 parent fa028da commit a23e911

File tree

7 files changed

+131
-24
lines changed

7 files changed

+131
-24
lines changed

src/components/drops/menu/dropdown.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import Flex from "@/components/templates/flex"
55
import Search from "@/components/search"
66
import Box from "@/components/templates/box"
77
import { mergeRefs } from "@/utils"
8+
import { useCallback } from "react"
89

910
const Container = styled(Flex)`
1011
${({ hideShadow }) =>
@@ -15,6 +16,19 @@ const Container = styled(Flex)`
1516

1617
const defaultEstimateSize = () => 28
1718

19+
const indexCalculatorByKey = {
20+
ArrowDown: (index, length) => Math.min(index + 1, length - 1),
21+
ArrowUp: index => Math.max(index - 1, 0),
22+
Home: () => 0,
23+
End: (_, length) => length - 1,
24+
default: index => index,
25+
}
26+
27+
const getNextIndex = (currentIndex, key, itemsLength) => {
28+
const calculator = indexCalculatorByKey[key] || indexCalculatorByKey.default
29+
return calculator(currentIndex, itemsLength)
30+
}
31+
1832
const Dropdown = forwardRef(
1933
(
2034
{
@@ -32,6 +46,9 @@ const Dropdown = forwardRef(
3246
gap = 0,
3347
estimateSize = defaultEstimateSize,
3448
close,
49+
enableKeyNavigation,
50+
activeIndex,
51+
setActiveIndex,
3552
...rest
3653
},
3754
forwardedRef
@@ -61,6 +78,31 @@ const Dropdown = forwardRef(
6178
estimateSize,
6279
})
6380

81+
const handleKeyDown = useCallback(
82+
event => {
83+
if (["ArrowDown", "ArrowUp", "Home", "End"].includes(event.code)) {
84+
setActiveIndex(prevIndex => {
85+
const nextIndex = getNextIndex(prevIndex, event.code, items.length)
86+
rowVirtualizer.scrollToIndex(nextIndex)
87+
return nextIndex
88+
})
89+
}
90+
},
91+
[rowVirtualizer, items, setActiveIndex]
92+
)
93+
94+
const virtualContainerProps = useMemo(() => {
95+
if (enableKeyNavigation)
96+
return {
97+
tabIndex: 0,
98+
role: "listbox",
99+
"aria-activedescendant": `item-${activeIndex}`,
100+
onKeyDown: handleKeyDown,
101+
}
102+
103+
return {}
104+
}, [enableKeyNavigation, activeIndex, handleKeyDown])
105+
64106
return (
65107
<Container
66108
as="ul"
@@ -86,6 +128,7 @@ const Dropdown = forwardRef(
86128
height: "100%",
87129
overflow: "auto",
88130
}}
131+
{...virtualContainerProps}
89132
>
90133
<div
91134
style={{
@@ -116,6 +159,7 @@ const Dropdown = forwardRef(
116159
value={value}
117160
onItemClick={onItemClick}
118161
close={close}
162+
{...(enableKeyNavigation ? { enableKeyNavigation: true, activeIndex } : {})}
119163
/>
120164
</div>
121165
))}

src/components/drops/menu/dropdownItem.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ export const ItemContainer = styled(Flex).attrs(props => ({
1313
cursor: ${({ cursor }) => cursor ?? "pointer"};
1414
opacity: ${({ disabled, selected }) => (selected ? 0.9 : disabled ? 0.4 : 1)};
1515
pointer-events: ${({ disabled }) => (disabled ? "none" : "auto")};
16+
background-color: ${props =>
17+
props.activeIndex == props.index ? getColor("borderSecondary")(props) : "none"};
1618
1719
&:hover {
1820
background-color: ${props => getColor("borderSecondary")(props)};
@@ -43,6 +45,7 @@ const DropdownItem = ({
4345
onItemClick,
4446
index,
4547
style,
48+
enableKeyNavigation,
4649
...rest
4750
}) => {
4851
const selected = selectedValue === value
@@ -54,11 +57,14 @@ const DropdownItem = ({
5457

5558
return (
5659
<ItemContainer
60+
id={`item-${index}`}
5761
data-index={index}
5862
aria-selected={selected}
5963
disabled={disabled}
6064
selected={selected}
6165
onClick={onSelect}
66+
index={index}
67+
{...(enableKeyNavigation ? { role: "option" } : {})}
6268
{...restItem}
6369
{...rest}
6470
style={style}
Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,48 @@
1-
import React from "react"
1+
import React, { forwardRef, useCallback, useState } from "react"
22
import { StyledOptionsContainer } from "./styled"
33
import Dropdown from "@/components/drops/menu/dropdown"
44
import DropdownItem from "@/components/drops/menu/dropdownItem"
55
import useAutocomplete from "./useAutocomplete"
66

7-
const Autocomplete = ({ value, autocompleteProps, Item = DropdownItem }) => {
8-
const { autocompleteOpen, suggestions, onItemClick } = useAutocomplete({
7+
const Autocomplete = forwardRef(({ value, autocompleteProps, onInputChange, onEsc }, ref) => {
8+
const [activeIndex, setActiveIndex] = useState(0)
9+
const { autocompleteOpen, close, suggestions, onItemClick } = useAutocomplete({
910
value,
11+
onInputChange,
1012
autocompleteProps,
1113
})
1214

15+
const onKeyDown = useCallback(
16+
e => {
17+
if (e.code == "Escape") {
18+
onEsc()
19+
close()
20+
} else if (e.code == "Enter") {
21+
onItemClick(suggestions[activeIndex]?.value)
22+
onEsc()
23+
close()
24+
}
25+
},
26+
[activeIndex, suggestions, onItemClick, onEsc, close]
27+
)
28+
1329
return (
1430
autocompleteOpen && (
1531
<StyledOptionsContainer>
16-
<Dropdown items={suggestions} Item={Item} onItemClick={onItemClick} width="100%" />
32+
<Dropdown
33+
ref={ref}
34+
items={suggestions}
35+
Item={DropdownItem}
36+
onItemClick={onItemClick}
37+
width="100%"
38+
onKeyDown={onKeyDown}
39+
enableKeyNavigation
40+
activeIndex={activeIndex}
41+
setActiveIndex={setActiveIndex}
42+
/>
1743
</StyledOptionsContainer>
1844
)
1945
)
20-
}
46+
})
2147

2248
export default Autocomplete

src/components/input/autocomplete/styled.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import Flex from "@/components/templates/flex"
33

44
export const StyledOptionsContainer = styled(Flex)`
55
width: 300px;
6+
max-height: 300px;
67
position: absolute;
78
left: 0;
89
top: 36px;
Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,29 @@
11
import { useCallback } from "react"
22
import { useState, useEffect } from "react"
33

4-
const useAutocomplete = ({ value, autocompleteProps = {} } = {}) => {
4+
const useAutocomplete = ({ value, onInputChange, autocompleteProps = {} }) => {
55
const [autocompleteOpen, setAutocompleteOpen] = useState()
66
const { suggestions = [] } = autocompleteProps || {}
77

8-
const onItemClick = useCallback(e => console.log(e), [])
8+
const close = useCallback(() => setAutocompleteOpen(false), [setAutocompleteOpen])
9+
10+
const onItemClick = useCallback(
11+
val => {
12+
if (typeof onInputChange == "function") {
13+
onInputChange({ target: { value: val } })
14+
close()
15+
}
16+
},
17+
[close, onInputChange]
18+
)
919

1020
useEffect(() => {
1121
if (suggestions.length) {
1222
setAutocompleteOpen(!!value.length)
1323
}
1424
}, [suggestions, value, setAutocompleteOpen])
1525

16-
return { autocompleteOpen, suggestions, onItemClick }
26+
return { autocompleteOpen, close, suggestions, onItemClick }
1727
}
1828

1929
export default useAutocomplete

src/components/input/input.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import React, { useMemo } from "react"
1+
import React, { useMemo, useRef, useCallback } from "react"
22
import Flex from "@/components/templates/flex"
33
import { TextMicro } from "@/components/typography"
44
import { Input, LabelText } from "./styled"
55
import Autocomplete from "./autocomplete"
6+
import { mergeRefs } from "@/utils"
67

78
const Error = ({ error }) => {
89
const errorMessage = error === true ? "invalid" : error
@@ -36,15 +37,34 @@ export const TextInput = ({
3637
autocompleteProps,
3738
...props
3839
}) => {
40+
const ref = useRef()
41+
const autocompleteMenuRef = useRef()
42+
43+
const onKeyDown = useCallback(
44+
e => {
45+
if (autocompleteMenuRef.current && ["ArrowDown", "ArrowUp"].includes(e.key)) {
46+
autocompleteMenuRef.current.focus()
47+
}
48+
},
49+
[autocompleteMenuRef?.current]
50+
)
51+
52+
const onAutocompleteEscape = useCallback(() => {
53+
if (ref?.current) {
54+
ref.current.focus()
55+
}
56+
}, [ref])
57+
3958
const autocompleteInputProps = useMemo(
4059
() =>
4160
autocompleteProps
4261
? {
4362
"aria-autocomplete": "list",
4463
"aria-controls": "autocomplete-list",
64+
onKeyDown,
4565
}
4666
: {},
47-
[]
67+
[autocompleteProps, onKeyDown]
4868
)
4969

5070
return (
@@ -76,7 +96,7 @@ export const TextInput = ({
7696
type="text"
7797
value={value}
7898
size={size}
79-
ref={inputRef}
99+
ref={mergeRefs(inputRef, ref)}
80100
error={error}
81101
hasValue={!!value}
82102
{...autocompleteInputProps}
@@ -92,7 +112,13 @@ export const TextInput = ({
92112
</Flex>
93113
{typeof hint === "string" ? <TextMicro color="textLite">{hint}</TextMicro> : !!hint && hint}
94114
{!hideErrorMessage ? <Error error={error} /> : null}
95-
<Autocomplete value={value} autocompleteProps={autocompleteProps} />
115+
<Autocomplete
116+
ref={autocompleteMenuRef}
117+
value={value}
118+
onEsc={onAutocompleteEscape}
119+
autocompleteProps={autocompleteProps}
120+
onInputChange={props.onChange}
121+
/>
96122
</Flex>
97123
)
98124
}

src/components/input/input.stories.js

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,14 @@ export const Basic = args => <TextInput {...args} />
1616
export const WithAutocomplete = () => {
1717
const [value, setValue] = useState("")
1818
const autocompleteProps = {
19-
suggestions: [
20-
{ value: "one", label: "one" },
21-
{ value: "two", label: "two" },
22-
{ value: "three", label: "three" },
23-
],
19+
suggestions: Array.from(Array(10000).keys()).map(i => ({ value: i, label: `Label ${i}` })),
2420
}
2521

26-
return (
27-
<TextInput
28-
value={value}
29-
onChange={e => setValue(e.target.value)}
30-
autocompleteProps={autocompleteProps}
31-
/>
32-
)
22+
const onChange = e => {
23+
setValue(e.target.value)
24+
}
25+
26+
return <TextInput value={value} onChange={onChange} autocompleteProps={autocompleteProps} />
3327
}
3428

3529
export default {

0 commit comments

Comments
 (0)