1
0
mirror of https://github.com/Unleash/unleash.git synced 2024-12-22 19:07:54 +01:00

feat: keyboard navigation in search (#4651)

This commit is contained in:
Mateusz Kwasniewski 2023-09-11 12:53:31 +02:00 committed by GitHub
parent 77fbac01e4
commit ba73d9a0d1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 194 additions and 31 deletions

View File

@ -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 (
<StyledContainer

View File

@ -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,
@ -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<ISearchInstructionsProps> = ({
condition={filter.options.length > 0}
show={
<StyledCode
tabIndex={0}
onKeyDown={onEnter(() =>
onClick(firstFilterOption(filter))
)}
onClick={() =>
onClick(firstFilterOption(filter))
}
@ -71,6 +79,10 @@ export const SearchInstructions: VFC<ISearchInstructionsProps> = ({
<>
{' or '}
<StyledCode
tabIndex={0}
onKeyDown={onEnter(() =>
onClick(secondFilterOption(filter))
)}
onClick={() => {
onClick(secondFilterOption(filter));
}}

View File

@ -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(
<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');
});

View File

@ -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<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">
@ -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>
@ -153,14 +177,7 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
searchableColumnsString={
searchableColumnsString
}
onClick={suggestion => {
onSuggestion(suggestion);
trackEvent('search-filter-suggestions', {
props: {
eventType: 'filter',
},
});
}}
onClick={onFilter}
/>
}
/>
@ -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>

View File

@ -0,0 +1,7 @@
export const onEnter = (callback: () => void) => {
return (event: React.KeyboardEvent<HTMLSpanElement>): void => {
if (event.key === 'Enter' || event.keyCode === 13) {
callback();
}
};
};

View File

@ -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);
});
});

View File

@ -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]);
};