From e61bc66f5491b73d51f8ddfa9f86edf7fe7c234d Mon Sep 17 00:00:00 2001 From: Amit Dhamu Date: Sat, 19 Mar 2022 15:21:36 +0000 Subject: [PATCH] Close suggestions if mouse click outside component --- src/InputSuggestions.tsx | 48 +++++++++++--------- src/__tests__/useSuggestions.test.tsx | 65 +++++++++++++++++++++------ src/useSuggestions.ts | 48 ++++++++++++++++++-- 3 files changed, 122 insertions(+), 39 deletions(-) diff --git a/src/InputSuggestions.tsx b/src/InputSuggestions.tsx index 36e0d9a..9286b9b 100644 --- a/src/InputSuggestions.tsx +++ b/src/InputSuggestions.tsx @@ -22,8 +22,13 @@ const InputSuggestions = ({ const inputSearchRef = React.useRef(null) const searchSuggestionsRef = React.useRef(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( @@ -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 && ( -
    - {results.map(suggestion => ( -
  • - {highlightKeywords - ? wrapElementText( - suggestion, - inputSearchRef.current?.value || '' - ) - : suggestion} -
  • - ))} -
- )} + {showSuggestions && ( +
    + {results.map(suggestion => ( +
  • + {highlightKeywords + ? wrapElementText( + suggestion, + inputSearchRef.current?.value || '' + ) + : suggestion} +
  • + ))} +
+ )} ) } diff --git a/src/__tests__/useSuggestions.test.tsx b/src/__tests__/useSuggestions.test.tsx index 4d4b3d8..50403e3 100644 --- a/src/__tests__/useSuggestions.test.tsx +++ b/src/__tests__/useSuggestions.test.tsx @@ -1,3 +1,5 @@ +import React from 'react' + import { screen } from '@testing-library/react' import { renderHook } from '@testing-library/react-hooks' @@ -9,6 +11,17 @@ describe('useSuggestions', () => { let inputRef: React.RefObject let listRef: React.RefObject let target: HTMLElement + const results = [ + + Twitter + , + + Facebook + , + + Reddit + , + ] beforeEach(() => { target = document.createElement('div') @@ -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: ' ' }, @@ -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: ' ' }, @@ -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: { @@ -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: { @@ -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() @@ -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: { @@ -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: { @@ -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) 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() + }) }) diff --git a/src/useSuggestions.ts b/src/useSuggestions.ts index 3953909..49550c9 100644 --- a/src/useSuggestions.ts +++ b/src/useSuggestions.ts @@ -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', @@ -16,13 +17,42 @@ enum ResultType { export const useSuggestions = ( inputSearchRef: React.RefObject, - searchSuggestionsRef: React.RefObject + searchSuggestionsRef: React.RefObject, + 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) => { @@ -82,7 +112,7 @@ export const useSuggestions = ( } const onResultsKeyDown = (e: React.KeyboardEvent) => { - 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) @@ -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, } }