Skip to content

Commit e61bc66

Browse files
committed
Close suggestions if mouse click outside component
1 parent 812b583 commit e61bc66

File tree

3 files changed

+122
-39
lines changed

3 files changed

+122
-39
lines changed

src/InputSuggestions.tsx

Lines changed: 26 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,13 @@ const InputSuggestions = ({
2222
const inputSearchRef = React.useRef<HTMLInputElement>(null)
2323
const searchSuggestionsRef = React.useRef<HTMLUListElement>(null)
2424

25-
const { selectInitialResult, onResultsHover, onResultsKeyDown } =
26-
useSuggestions(inputSearchRef, searchSuggestionsRef)
25+
const {
26+
selectInitialResult,
27+
onResultsHover,
28+
onResultsKeyDown,
29+
showSuggestions,
30+
onInputFocus,
31+
} = useSuggestions(inputSearchRef, searchSuggestionsRef, results)
2732

2833
const filterSuggestions = (e: { target: { value: string } }) =>
2934
setResults(
@@ -49,30 +54,29 @@ const InputSuggestions = ({
4954
filterSuggestions(e)
5055
}}
5156
onKeyDown={selectInitialResult}
57+
onFocus={onInputFocus}
5258
spellCheck={false}
5359
autoComplete="off"
5460
autoCapitalize="off"
5561
/>
56-
{inputSearchRef.current &&
57-
inputSearchRef.current.value.length > 0 &&
58-
results.length > 0 && (
59-
<ul ref={searchSuggestionsRef}>
60-
{results.map(suggestion => (
61-
<li
62-
key={getElementText(suggestion)}
63-
onMouseOver={onResultsHover}
64-
onKeyDown={onResultsKeyDown}
65-
>
66-
{highlightKeywords
67-
? wrapElementText(
68-
suggestion,
69-
inputSearchRef.current?.value || ''
70-
)
71-
: suggestion}
72-
</li>
73-
))}
74-
</ul>
75-
)}
62+
{showSuggestions && (
63+
<ul ref={searchSuggestionsRef}>
64+
{results.map(suggestion => (
65+
<li
66+
key={getElementText(suggestion)}
67+
onMouseOver={onResultsHover}
68+
onKeyDown={onResultsKeyDown}
69+
>
70+
{highlightKeywords
71+
? wrapElementText(
72+
suggestion,
73+
inputSearchRef.current?.value || ''
74+
)
75+
: suggestion}
76+
</li>
77+
))}
78+
</ul>
79+
)}
7680
</Styled>
7781
)
7882
}

src/__tests__/useSuggestions.test.tsx

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import React from 'react'
2+
13
import { screen } from '@testing-library/react'
24
import { renderHook } from '@testing-library/react-hooks'
35

@@ -9,6 +11,17 @@ describe('useSuggestions', () => {
911
let inputRef: React.RefObject<HTMLInputElement>
1012
let listRef: React.RefObject<HTMLUListElement>
1113
let target: HTMLElement
14+
const results = [
15+
<a key="1" href="https://twitter.com">
16+
Twitter
17+
</a>,
18+
<a key="2" href="https://facebook.com">
19+
Facebook
20+
</a>,
21+
<a key="3" href="https://reddit.com">
22+
Reddit
23+
</a>,
24+
]
1225

1326
beforeEach(() => {
1427
target = document.createElement('div')
@@ -45,15 +58,17 @@ describe('useSuggestions', () => {
4558
})
4659

4760
it('sets the tab index for each list item', () => {
48-
renderHook(() => useSuggestions(inputRef, listRef))
61+
renderHook(() => useSuggestions(inputRef, listRef, results))
4962

5063
screen.getAllByRole('link').forEach(linkItem => {
51-
expect(linkItem).toHaveAttribute('tabIndex', '-1')
64+
expect(linkItem).toHaveAttribute('tabIndex', '0')
5265
})
5366
})
5467

5568
it('selects the first initial result', () => {
56-
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
69+
const { result } = renderHook(() =>
70+
useSuggestions(inputRef, listRef, results)
71+
)
5772

5873
result.current.selectInitialResult({
5974
currentTarget: { value: ' ' },
@@ -65,7 +80,9 @@ describe('useSuggestions', () => {
6580
})
6681

6782
it('selects the last initial result', () => {
68-
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
83+
const { result } = renderHook(() =>
84+
useSuggestions(inputRef, listRef, results)
85+
)
6986

7087
result.current.selectInitialResult({
7188
currentTarget: { value: ' ' },
@@ -77,7 +94,9 @@ describe('useSuggestions', () => {
7794
})
7895

7996
it('sets focus on the hovered element', () => {
80-
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
97+
const { result } = renderHook(() =>
98+
useSuggestions(inputRef, listRef, results)
99+
)
81100

82101
result.current.onResultsHover({
83102
currentTarget: {
@@ -97,7 +116,9 @@ describe('useSuggestions', () => {
97116
})
98117

99118
it('navigates through search suggestions', () => {
100-
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
119+
const { result } = renderHook(() =>
120+
useSuggestions(inputRef, listRef, results)
121+
)
101122
const triggerEvent = (next: string, previous: string, key: string) =>
102123
({
103124
currentTarget: {
@@ -121,9 +142,7 @@ describe('useSuggestions', () => {
121142

122143
expect(screen.getByRole('link', { name: 'Facebook' })).toHaveFocus()
123144

124-
result.current.onResultsKeyDown(
125-
triggerEvent('Twitter', 'Facebook', 'ArrowDown')
126-
)
145+
result.current.onResultsKeyDown(triggerEvent('Twitter', 'Facebook', 'Tab'))
127146

128147
expect(screen.getByRole('link', { name: 'Twitter' })).toHaveFocus()
129148

@@ -135,7 +154,9 @@ describe('useSuggestions', () => {
135154
})
136155

137156
it('navigates to first result if nextSibling unavailable', () => {
138-
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
157+
const { result } = renderHook(() =>
158+
useSuggestions(inputRef, listRef, results)
159+
)
139160

140161
result.current.onResultsKeyDown({
141162
currentTarget: {
@@ -149,7 +170,9 @@ describe('useSuggestions', () => {
149170
})
150171

151172
it('navigates to last result if previousSibling unavailable', () => {
152-
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
173+
const { result } = renderHook(() =>
174+
useSuggestions(inputRef, listRef, results)
175+
)
153176

154177
result.current.onResultsKeyDown({
155178
currentTarget: {
@@ -162,17 +185,31 @@ describe('useSuggestions', () => {
162185
expect(screen.getByRole('link', { name: 'Twitter' })).toHaveFocus()
163186
})
164187

165-
it('sets focus back to input element if arrow keys not pressed', () => {
166-
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
188+
it('sets focus back to input element if arrow keys or tab not pressed', () => {
189+
const { result } = renderHook(() =>
190+
useSuggestions(inputRef, listRef, results)
191+
)
167192

168193
result.current.onResultsKeyDown({
169194
currentTarget: {
170195
value: 'r',
171196
},
172-
key: 'Tab',
197+
key: 'e',
173198
preventDefault: jest.fn(),
174199
} as unknown as React.KeyboardEvent<HTMLLIElement>)
175200

176201
expect(screen.getByRole('searchbox')).toHaveFocus()
177202
})
203+
204+
it('sets showSuggestions to true if results exist and input not empty', () => {
205+
if (inputRef.current) {
206+
inputRef.current.value = 'r'
207+
}
208+
209+
const { result } = renderHook(() =>
210+
useSuggestions(inputRef, listRef, results)
211+
)
212+
213+
expect(result.current.showSuggestions).toBeTruthy()
214+
})
178215
})

src/useSuggestions.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import React from 'react'
33
const ARROW_KEY_DOWN = 'ArrowDown'
44
const ARROW_KEY_UP = 'ArrowUp'
55
const ENTER = 'Enter'
6+
const TAB = 'Tab'
67

78
enum SiblingType {
89
NEXT = 'nextSibling',
@@ -16,13 +17,42 @@ enum ResultType {
1617

1718
export const useSuggestions = (
1819
inputSearchRef: React.RefObject<HTMLInputElement>,
19-
searchSuggestionsRef: React.RefObject<HTMLUListElement>
20+
searchSuggestionsRef: React.RefObject<HTMLUListElement>,
21+
results: React.ReactNode[]
2022
) => {
23+
const [showSuggestions, setShowSuggestions] = React.useState(false)
24+
25+
const handleClickOutside = (e: MouseEvent) => {
26+
if (
27+
showSuggestions &&
28+
!searchSuggestionsRef.current?.contains(e.target as Node)
29+
) {
30+
setShowSuggestions(false)
31+
}
32+
}
33+
34+
React.useEffect(() => {
35+
setShowSuggestions(
36+
Boolean(
37+
inputSearchRef &&
38+
inputSearchRef.current &&
39+
inputSearchRef.current.value.length > 0 &&
40+
results.length > 0
41+
)
42+
)
43+
}, [results])
44+
2145
React.useEffect(() => {
2246
searchSuggestionsRef.current?.querySelectorAll('li')?.forEach(el => {
2347
// eslint-disable-next-line no-param-reassign
24-
;(el.firstChild as HTMLElement).tabIndex = -1
48+
;(el.firstChild as HTMLElement).tabIndex = 0
2549
})
50+
51+
document.addEventListener('mousedown', handleClickOutside)
52+
53+
return () => {
54+
document.removeEventListener('mousedown', handleClickOutside)
55+
}
2656
}, [searchSuggestionsRef.current])
2757

2858
const selectElement = (type: ResultType) => {
@@ -82,7 +112,7 @@ export const useSuggestions = (
82112
}
83113

84114
const onResultsKeyDown = (e: React.KeyboardEvent<HTMLLIElement>) => {
85-
if (e.key === ARROW_KEY_DOWN) {
115+
if ([ARROW_KEY_DOWN, TAB].includes(e.key)) {
86116
selectResult(e, SiblingType.NEXT)
87117
} else if (e.key === ARROW_KEY_UP) {
88118
selectResult(e, SiblingType.PREVIOUS)
@@ -91,9 +121,21 @@ export const useSuggestions = (
91121
}
92122
}
93123

124+
const onInputFocus = (e: { currentTarget: { value: string } }) => {
125+
if (
126+
document.activeElement === inputSearchRef.current &&
127+
e.currentTarget.value !== ''
128+
) {
129+
setShowSuggestions(true)
130+
}
131+
}
132+
94133
return {
95134
selectInitialResult,
96135
onResultsHover,
97136
onResultsKeyDown,
137+
showSuggestions,
138+
setShowSuggestions,
139+
onInputFocus,
98140
}
99141
}

0 commit comments

Comments
 (0)