Skip to content

Commit

Permalink
Close suggestions if mouse click outside component
Browse files Browse the repository at this point in the history
  • Loading branch information
adhamu committed Mar 19, 2022
1 parent 812b583 commit e61bc66
Show file tree
Hide file tree
Showing 3 changed files with 122 additions and 39 deletions.
48 changes: 26 additions & 22 deletions src/InputSuggestions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,13 @@ const InputSuggestions = ({
const inputSearchRef = React.useRef<HTMLInputElement>(null)
const searchSuggestionsRef = React.useRef<HTMLUListElement>(null)

const { selectInitialResult, onResultsHover, onResultsKeyDown } =
useSuggestions(inputSearchRef, searchSuggestionsRef)
const {
selectInitialResult,
onResultsHover,
onResultsKeyDown,
showSuggestions,
onInputFocus,
} = useSuggestions(inputSearchRef, searchSuggestionsRef, results)

const filterSuggestions = (e: { target: { value: string } }) =>
setResults(
Expand All @@ -49,30 +54,29 @@ const InputSuggestions = ({
filterSuggestions(e)
}}
onKeyDown={selectInitialResult}
onFocus={onInputFocus}
spellCheck={false}
autoComplete="off"
autoCapitalize="off"
/>
{inputSearchRef.current &&
inputSearchRef.current.value.length > 0 &&
results.length > 0 && (
<ul ref={searchSuggestionsRef}>
{results.map(suggestion => (
<li
key={getElementText(suggestion)}
onMouseOver={onResultsHover}
onKeyDown={onResultsKeyDown}
>
{highlightKeywords
? wrapElementText(
suggestion,
inputSearchRef.current?.value || ''
)
: suggestion}
</li>
))}
</ul>
)}
{showSuggestions && (
<ul ref={searchSuggestionsRef}>
{results.map(suggestion => (
<li
key={getElementText(suggestion)}
onMouseOver={onResultsHover}
onKeyDown={onResultsKeyDown}
>
{highlightKeywords
? wrapElementText(
suggestion,
inputSearchRef.current?.value || ''
)
: suggestion}
</li>
))}
</ul>
)}
</Styled>
)
}
Expand Down
65 changes: 51 additions & 14 deletions src/__tests__/useSuggestions.test.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import React from 'react'

import { screen } from '@testing-library/react'
import { renderHook } from '@testing-library/react-hooks'

Expand All @@ -9,6 +11,17 @@ describe('useSuggestions', () => {
let inputRef: React.RefObject<HTMLInputElement>
let listRef: React.RefObject<HTMLUListElement>
let target: HTMLElement
const results = [
<a key="1" href="https://twitter.com">
Twitter
</a>,
<a key="2" href="https://facebook.com">
Facebook
</a>,
<a key="3" href="https://reddit.com">
Reddit
</a>,
]

beforeEach(() => {
target = document.createElement('div')
Expand Down Expand Up @@ -45,15 +58,17 @@ describe('useSuggestions', () => {
})

it('sets the tab index for each list item', () => {
renderHook(() => useSuggestions(inputRef, listRef))
renderHook(() => useSuggestions(inputRef, listRef, results))

screen.getAllByRole('link').forEach(linkItem => {
expect(linkItem).toHaveAttribute('tabIndex', '-1')
expect(linkItem).toHaveAttribute('tabIndex', '0')
})
})

it('selects the first initial result', () => {
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
const { result } = renderHook(() =>
useSuggestions(inputRef, listRef, results)
)

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

it('selects the last initial result', () => {
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
const { result } = renderHook(() =>
useSuggestions(inputRef, listRef, results)
)

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

it('sets focus on the hovered element', () => {
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
const { result } = renderHook(() =>
useSuggestions(inputRef, listRef, results)
)

result.current.onResultsHover({
currentTarget: {
Expand All @@ -97,7 +116,9 @@ describe('useSuggestions', () => {
})

it('navigates through search suggestions', () => {
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
const { result } = renderHook(() =>
useSuggestions(inputRef, listRef, results)
)
const triggerEvent = (next: string, previous: string, key: string) =>
({
currentTarget: {
Expand All @@ -121,9 +142,7 @@ describe('useSuggestions', () => {

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

result.current.onResultsKeyDown(
triggerEvent('Twitter', 'Facebook', 'ArrowDown')
)
result.current.onResultsKeyDown(triggerEvent('Twitter', 'Facebook', 'Tab'))

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

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

it('navigates to first result if nextSibling unavailable', () => {
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
const { result } = renderHook(() =>
useSuggestions(inputRef, listRef, results)
)

result.current.onResultsKeyDown({
currentTarget: {
Expand All @@ -149,7 +170,9 @@ describe('useSuggestions', () => {
})

it('navigates to last result if previousSibling unavailable', () => {
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
const { result } = renderHook(() =>
useSuggestions(inputRef, listRef, results)
)

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

it('sets focus back to input element if arrow keys not pressed', () => {
const { result } = renderHook(() => useSuggestions(inputRef, listRef))
it('sets focus back to input element if arrow keys or tab not pressed', () => {
const { result } = renderHook(() =>
useSuggestions(inputRef, listRef, results)
)

result.current.onResultsKeyDown({
currentTarget: {
value: 'r',
},
key: 'Tab',
key: 'e',
preventDefault: jest.fn(),
} as unknown as React.KeyboardEvent<HTMLLIElement>)

expect(screen.getByRole('searchbox')).toHaveFocus()
})

it('sets showSuggestions to true if results exist and input not empty', () => {
if (inputRef.current) {
inputRef.current.value = 'r'
}

const { result } = renderHook(() =>
useSuggestions(inputRef, listRef, results)
)

expect(result.current.showSuggestions).toBeTruthy()
})
})
48 changes: 45 additions & 3 deletions src/useSuggestions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import React from 'react'
const ARROW_KEY_DOWN = 'ArrowDown'
const ARROW_KEY_UP = 'ArrowUp'
const ENTER = 'Enter'
const TAB = 'Tab'

enum SiblingType {
NEXT = 'nextSibling',
Expand All @@ -16,13 +17,42 @@ enum ResultType {

export const useSuggestions = (
inputSearchRef: React.RefObject<HTMLInputElement>,
searchSuggestionsRef: React.RefObject<HTMLUListElement>
searchSuggestionsRef: React.RefObject<HTMLUListElement>,
results: React.ReactNode[]
) => {
const [showSuggestions, setShowSuggestions] = React.useState(false)

const handleClickOutside = (e: MouseEvent) => {
if (
showSuggestions &&
!searchSuggestionsRef.current?.contains(e.target as Node)
) {
setShowSuggestions(false)
}
}

React.useEffect(() => {
setShowSuggestions(
Boolean(
inputSearchRef &&
inputSearchRef.current &&
inputSearchRef.current.value.length > 0 &&
results.length > 0
)
)
}, [results])

React.useEffect(() => {
searchSuggestionsRef.current?.querySelectorAll('li')?.forEach(el => {
// eslint-disable-next-line no-param-reassign
;(el.firstChild as HTMLElement).tabIndex = -1
;(el.firstChild as HTMLElement).tabIndex = 0
})

document.addEventListener('mousedown', handleClickOutside)

return () => {
document.removeEventListener('mousedown', handleClickOutside)
}
}, [searchSuggestionsRef.current])

const selectElement = (type: ResultType) => {
Expand Down Expand Up @@ -82,7 +112,7 @@ export const useSuggestions = (
}

const onResultsKeyDown = (e: React.KeyboardEvent<HTMLLIElement>) => {
if (e.key === ARROW_KEY_DOWN) {
if ([ARROW_KEY_DOWN, TAB].includes(e.key)) {
selectResult(e, SiblingType.NEXT)
} else if (e.key === ARROW_KEY_UP) {
selectResult(e, SiblingType.PREVIOUS)
Expand All @@ -91,9 +121,21 @@ export const useSuggestions = (
}
}

const onInputFocus = (e: { currentTarget: { value: string } }) => {
if (
document.activeElement === inputSearchRef.current &&
e.currentTarget.value !== ''
) {
setShowSuggestions(true)
}
}

return {
selectInitialResult,
onResultsHover,
onResultsKeyDown,
showSuggestions,
setShowSuggestions,
onInputFocus,
}
}

0 comments on commit e61bc66

Please sign in to comment.