From ba73d9a0d19bd8201234e3733ec7817bd684522b Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Mon, 11 Sep 2023 12:53:31 +0200 Subject: [PATCH] feat: keyboard navigation in search (#4651) --- .../src/component/common/Search/Search.tsx | 4 +- .../SearchInstructions/SearchInstructions.tsx | 14 +++- .../SearchSuggestions.test.tsx | 49 +++++++++++++ .../SearchSuggestions/SearchSuggestions.tsx | 68 +++++++++++-------- .../Search/SearchSuggestions/onEnter.ts | 7 ++ frontend/src/hooks/useOnBlur.test.tsx | 52 ++++++++++++++ frontend/src/hooks/useOnBlur.ts | 31 +++++++++ 7 files changed, 194 insertions(+), 31 deletions(-) create mode 100644 frontend/src/component/common/Search/SearchSuggestions/onEnter.ts create mode 100644 frontend/src/hooks/useOnBlur.test.tsx create mode 100644 frontend/src/hooks/useOnBlur.ts diff --git a/frontend/src/component/common/Search/Search.tsx b/frontend/src/component/common/Search/Search.tsx index 31f5cfb933..396d24d0ab 100644 --- a/frontend/src/component/common/Search/Search.tsx +++ b/frontend/src/component/common/Search/Search.tsx @@ -9,6 +9,7 @@ import { useKeyboardShortcut } from 'hooks/useKeyboardShortcut'; import { SEARCH_INPUT } from 'utils/testIds'; import { useOnClickOutside } from 'hooks/useOnClickOutside'; import { useSavedQuery } from './useSavedQuery'; +import { useOnBlur } from 'hooks/useOnBlur'; interface ISearchProps { id?: string; @@ -111,7 +112,7 @@ export const Search = ({ } ); useKeyboardShortcut({ key: 'Escape' }, () => { - if (document.activeElement === searchInputRef.current) { + if (searchContainerRef.current?.contains(document.activeElement)) { searchInputRef.current?.blur(); hideSuggestions(); } @@ -119,6 +120,7 @@ export const Search = ({ const placeholder = `${customPlaceholder ?? 'Search'} (${hotkey})`; useOnClickOutside([searchContainerRef], hideSuggestions); + useOnBlur(searchContainerRef, hideSuggestions); return ( ({ fontSize: theme.fontSizes.smallBody, @@ -13,10 +14,13 @@ export const StyledCode = styled('span')(({ theme }) => ({ padding: theme.spacing(0.2, 1), borderRadius: theme.spacing(0.5), cursor: 'pointer', - '&:hover': { + '&:hover, &:focus-visible': { transition: 'background-color 0.2s ease-in-out', backgroundColor: theme.palette.seen.primary, }, + '&:focus-visible': { + outline: `2px solid ${theme.palette.primary.main}`, + }, })); const StyledFilterHint = styled('p')(({ theme }) => ({ @@ -57,6 +61,10 @@ export const SearchInstructions: VFC = ({ condition={filter.options.length > 0} show={ + onClick(firstFilterOption(filter)) + )} onClick={() => onClick(firstFilterOption(filter)) } @@ -71,6 +79,10 @@ export const SearchInstructions: VFC = ({ <> {' or '} + onClick(secondFilterOption(filter)) + )} onClick={() => { onClick(secondFilterOption(filter)); }} diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.test.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.test.tsx index ac10ca6b76..2ca89354af 100644 --- a/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.test.tsx +++ b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.test.tsx @@ -33,6 +33,36 @@ const searchContext = { ], }; +const searchContextWithoutFilters = { + data: [ + { + title: 'Title A', + environment: 'prod', + }, + { + title: 'Title B', + environment: 'dev env', + }, + { + title: 'Title C', + environment: 'stage\npre-prod', + }, + ], + searchValue: '', + columns: [ + { + Header: 'Title', + searchable: true, + accessor: 'title', + }, + { + Header: 'Environment', + accessor: 'environment', + searchable: true, + }, + ], +}; + test('displays search and filter instructions when no search value is provided', () => { let recordedSuggestion = ''; render( @@ -106,3 +136,22 @@ test('displays search and filter instructions when filter value is provided', () screen.getByText(/Title A/i).click(); expect(recordedSuggestion).toBe('environment:"dev env" Title A'); }); + +test('displays search instructions without filters', () => { + let recordedSuggestion = ''; + render( + { + recordedSuggestion = suggestion; + }} + getSearchContext={() => searchContextWithoutFilters} + /> + ); + + expect( + screen.getByText(/Start typing to search in Title, Environment/i) + ).toBeInTheDocument(); + + screen.getByText(/Title A/i).click(); + expect(recordedSuggestion).toBe('Title A'); +}); diff --git a/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx index 6d16ccc77a..e954c68a15 100644 --- a/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx +++ b/frontend/src/component/common/Search/SearchSuggestions/SearchSuggestions.tsx @@ -14,6 +14,7 @@ import { StyledCode, } from './SearchInstructions/SearchInstructions'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; +import { onEnter } from './onEnter'; const StyledPaper = styled(Paper)(({ theme }) => ({ position: 'absolute', @@ -103,9 +104,37 @@ export const SearchSuggestions: VFC = ({ ? getColumnValues(searchableColumns[0], searchContext.data[0]) : 'example-search-text'; - const selectedFilter = filters.map( - filter => `${filter.name}:${filter.suggestedOption}` - )[0]; + const selectedFilter = + filters.length === 0 + ? '' + : filters.map( + filter => `${filter.name}:${filter.suggestedOption}` + )[0]; + + const onFilter = (suggestion: string) => { + onSuggestion(suggestion); + trackEvent('search-filter-suggestions', { + props: { + eventType: 'filter', + }, + }); + }; + const onSearchAndFilter = () => { + onSuggestion((selectedFilter + ' ' + suggestedTextSearch).trim()); + trackEvent('search-filter-suggestions', { + props: { + eventType: 'search and filter', + }, + }); + }; + const onSavedQuery = () => { + onSuggestion(savedQuery || ''); + trackEvent('search-filter-suggestions', { + props: { + eventType: 'saved query', + }, + }); + }; return ( @@ -116,14 +145,9 @@ export const SearchSuggestions: VFC = ({ { - onSuggestion(savedQuery || ''); - trackEvent('search-filter-suggestions', { - props: { - eventType: 'saved query', - }, - }); - }} + tabIndex={0} + onClick={onSavedQuery} + onKeyDown={onEnter(onSavedQuery)} > {savedQuery} @@ -153,14 +177,7 @@ export const SearchSuggestions: VFC = ({ searchableColumnsString={ searchableColumnsString } - onClick={suggestion => { - onSuggestion(suggestion); - trackEvent('search-filter-suggestions', { - props: { - eventType: 'filter', - }, - }); - }} + onClick={onFilter} /> } /> @@ -173,16 +190,9 @@ export const SearchSuggestions: VFC = ({ show="Combine filters and search: " /> { - onSuggestion( - selectedFilter + ' ' + suggestedTextSearch - ); - trackEvent('search-filter-suggestions', { - props: { - eventType: 'search and filter', - }, - }); - }} + tabIndex={0} + onClick={onSearchAndFilter} + onKeyDown={onEnter(onSearchAndFilter)} > {selectedFilter}{' '} {suggestedTextSearch} diff --git a/frontend/src/component/common/Search/SearchSuggestions/onEnter.ts b/frontend/src/component/common/Search/SearchSuggestions/onEnter.ts new file mode 100644 index 0000000000..e6a5c8d99a --- /dev/null +++ b/frontend/src/component/common/Search/SearchSuggestions/onEnter.ts @@ -0,0 +1,7 @@ +export const onEnter = (callback: () => void) => { + return (event: React.KeyboardEvent): void => { + if (event.key === 'Enter' || event.keyCode === 13) { + callback(); + } + }; +}; diff --git a/frontend/src/hooks/useOnBlur.test.tsx b/frontend/src/hooks/useOnBlur.test.tsx new file mode 100644 index 0000000000..c5dcb9c42d --- /dev/null +++ b/frontend/src/hooks/useOnBlur.test.tsx @@ -0,0 +1,52 @@ +import { render, screen, waitFor } from '@testing-library/react'; +import { useRef } from 'react'; +import { useOnBlur } from './useOnBlur'; + +function TestComponent(props: { onBlurHandler: () => void }) { + const divRef = useRef(null); + useOnBlur(divRef, props.onBlurHandler); + + return ( +
+
+ Inside +
+
+ Outside +
+
+ ); +} + +test('should not call the callback when blurring within the same container', async () => { + let mockCallbackCallCount = 0; + const mockCallback = () => mockCallbackCallCount++; + + render(); + + const insideDiv = screen.getByTestId('inside'); + + insideDiv.focus(); + insideDiv.blur(); + + await waitFor(() => { + expect(mockCallbackCallCount).toBe(0); + }); +}); + +test('should call the callback when blurring outside of the container', async () => { + let mockCallbackCallCount = 0; + const mockCallback = () => mockCallbackCallCount++; + + render(); + + const insideDiv = screen.getByTestId('inside'); + const outsideDiv = screen.getByTestId('outside'); + + insideDiv.focus(); + outsideDiv.focus(); + + await waitFor(() => { + expect(mockCallbackCallCount).toBe(1); + }); +}); diff --git a/frontend/src/hooks/useOnBlur.ts b/frontend/src/hooks/useOnBlur.ts new file mode 100644 index 0000000000..783ea9dd6b --- /dev/null +++ b/frontend/src/hooks/useOnBlur.ts @@ -0,0 +1,31 @@ +import { useEffect } from 'react'; + +export const useOnBlur = ( + containerRef: React.RefObject, + callback: () => void +): void => { + useEffect(() => { + const handleBlur = (event: FocusEvent) => { + // setTimeout is used because activeElement might not immediately be the new focused element after a blur event + setTimeout(() => { + if ( + containerRef.current && + !containerRef.current.contains(document.activeElement) + ) { + callback(); + } + }, 0); + }; + + const containerElement = containerRef.current; + if (containerElement) { + containerElement.addEventListener('blur', handleBlur, true); + } + + return () => { + if (containerElement) { + containerElement.removeEventListener('blur', handleBlur, true); + } + }; + }, [containerRef, callback]); +};