Skip to content

Commit

Permalink
feat: keyboard navigation in search (#4651)
Browse files Browse the repository at this point in the history
  • Loading branch information
kwasniew committed Sep 11, 2023
1 parent 77fbac0 commit ba73d9a
Show file tree
Hide file tree
Showing 7 changed files with 194 additions and 31 deletions.
4 changes: 3 additions & 1 deletion frontend/src/component/common/Search/Search.tsx
Expand Up @@ -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;
Expand Down Expand Up @@ -111,14 +112,15 @@ export const Search = ({
}
);
useKeyboardShortcut({ key: 'Escape' }, () => {
if (document.activeElement === searchInputRef.current) {
if (searchContainerRef.current?.contains(document.activeElement)) {
searchInputRef.current?.blur();
hideSuggestions();
}
});
const placeholder = `${customPlaceholder ?? 'Search'} (${hotkey})`;

useOnClickOutside([searchContainerRef], hideSuggestions);
useOnBlur(searchContainerRef, hideSuggestions);

return (
<StyledContainer
Expand Down
@@ -1,6 +1,7 @@
import { styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { VFC } from 'react';
import { onEnter } from '../onEnter';

const StyledHeader = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody,
Expand All @@ -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 }) => ({
Expand Down Expand Up @@ -57,6 +61,10 @@ export const SearchInstructions: VFC<ISearchInstructionsProps> = ({
condition={filter.options.length > 0}
show={
<StyledCode
tabIndex={0}
onKeyDown={onEnter(() =>
onClick(firstFilterOption(filter))
)}
onClick={() =>
onClick(firstFilterOption(filter))
}
Expand All @@ -71,6 +79,10 @@ export const SearchInstructions: VFC<ISearchInstructionsProps> = ({
<>
{' or '}
<StyledCode
tabIndex={0}
onKeyDown={onEnter(() =>
onClick(secondFilterOption(filter))
)}
onClick={() => {
onClick(secondFilterOption(filter));
}}
Expand Down
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
<SearchSuggestions
onSuggestion={suggestion => {
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');
});
Expand Up @@ -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',
Expand Down Expand Up @@ -103,9 +104,37 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
? 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 (
<StyledPaper className="dropdown-outline">
Expand All @@ -116,14 +145,9 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
<StyledBox>
<StyledHistory />
<StyledCode
onClick={() => {
onSuggestion(savedQuery || '');
trackEvent('search-filter-suggestions', {
props: {
eventType: 'saved query',
},
});
}}
tabIndex={0}
onClick={onSavedQuery}
onKeyDown={onEnter(onSavedQuery)}
>
<span>{savedQuery}</span>
</StyledCode>
Expand Down Expand Up @@ -153,14 +177,7 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
searchableColumnsString={
searchableColumnsString
}
onClick={suggestion => {
onSuggestion(suggestion);
trackEvent('search-filter-suggestions', {
props: {
eventType: 'filter',
},
});
}}
onClick={onFilter}
/>
}
/>
Expand All @@ -173,16 +190,9 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
show="Combine filters and search: "
/>
<StyledCode
onClick={() => {
onSuggestion(
selectedFilter + ' ' + suggestedTextSearch
);
trackEvent('search-filter-suggestions', {
props: {
eventType: 'search and filter',
},
});
}}
tabIndex={0}
onClick={onSearchAndFilter}
onKeyDown={onEnter(onSearchAndFilter)}
>
<span key={selectedFilter}>{selectedFilter}</span>{' '}
<span>{suggestedTextSearch}</span>
Expand Down
@@ -0,0 +1,7 @@
export const onEnter = (callback: () => void) => {
return (event: React.KeyboardEvent<HTMLSpanElement>): void => {
if (event.key === 'Enter' || event.keyCode === 13) {
callback();
}
};
};
52 changes: 52 additions & 0 deletions 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 (
<div data-testid="wrapper">
<div tabIndex={0} data-testid="inside" ref={divRef}>
Inside
</div>
<div tabIndex={0} data-testid="outside">
Outside
</div>
</div>
);
}

test('should not call the callback when blurring within the same container', async () => {
let mockCallbackCallCount = 0;
const mockCallback = () => mockCallbackCallCount++;

render(<TestComponent onBlurHandler={mockCallback} />);

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(<TestComponent onBlurHandler={mockCallback} />);

const insideDiv = screen.getByTestId('inside');
const outsideDiv = screen.getByTestId('outside');

insideDiv.focus();
outsideDiv.focus();

await waitFor(() => {
expect(mockCallbackCallCount).toBe(1);
});
});
31 changes: 31 additions & 0 deletions frontend/src/hooks/useOnBlur.ts
@@ -0,0 +1,31 @@
import { useEffect } from 'react';

export const useOnBlur = (
containerRef: React.RefObject<HTMLElement>,
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]);
};

0 comments on commit ba73d9a

Please sign in to comment.