1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-04-24 01:18:01 +02: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 { SEARCH_INPUT } from 'utils/testIds';
import { useOnClickOutside } from 'hooks/useOnClickOutside'; import { useOnClickOutside } from 'hooks/useOnClickOutside';
import { useSavedQuery } from './useSavedQuery'; import { useSavedQuery } from './useSavedQuery';
import { useOnBlur } from 'hooks/useOnBlur';
interface ISearchProps { interface ISearchProps {
id?: string; id?: string;
@ -111,7 +112,7 @@ export const Search = ({
} }
); );
useKeyboardShortcut({ key: 'Escape' }, () => { useKeyboardShortcut({ key: 'Escape' }, () => {
if (document.activeElement === searchInputRef.current) { if (searchContainerRef.current?.contains(document.activeElement)) {
searchInputRef.current?.blur(); searchInputRef.current?.blur();
hideSuggestions(); hideSuggestions();
} }
@ -119,6 +120,7 @@ export const Search = ({
const placeholder = `${customPlaceholder ?? 'Search'} (${hotkey})`; const placeholder = `${customPlaceholder ?? 'Search'} (${hotkey})`;
useOnClickOutside([searchContainerRef], hideSuggestions); useOnClickOutside([searchContainerRef], hideSuggestions);
useOnBlur(searchContainerRef, hideSuggestions);
return ( return (
<StyledContainer <StyledContainer

View File

@ -1,6 +1,7 @@
import { styled } from '@mui/material'; import { styled } from '@mui/material';
import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender'; import { ConditionallyRender } from 'component/common/ConditionallyRender/ConditionallyRender';
import { VFC } from 'react'; import { VFC } from 'react';
import { onEnter } from '../onEnter';
const StyledHeader = styled('span')(({ theme }) => ({ const StyledHeader = styled('span')(({ theme }) => ({
fontSize: theme.fontSizes.smallBody, fontSize: theme.fontSizes.smallBody,
@ -13,10 +14,13 @@ export const StyledCode = styled('span')(({ theme }) => ({
padding: theme.spacing(0.2, 1), padding: theme.spacing(0.2, 1),
borderRadius: theme.spacing(0.5), borderRadius: theme.spacing(0.5),
cursor: 'pointer', cursor: 'pointer',
'&:hover': { '&:hover, &:focus-visible': {
transition: 'background-color 0.2s ease-in-out', transition: 'background-color 0.2s ease-in-out',
backgroundColor: theme.palette.seen.primary, backgroundColor: theme.palette.seen.primary,
}, },
'&:focus-visible': {
outline: `2px solid ${theme.palette.primary.main}`,
},
})); }));
const StyledFilterHint = styled('p')(({ theme }) => ({ const StyledFilterHint = styled('p')(({ theme }) => ({
@ -57,6 +61,10 @@ export const SearchInstructions: VFC<ISearchInstructionsProps> = ({
condition={filter.options.length > 0} condition={filter.options.length > 0}
show={ show={
<StyledCode <StyledCode
tabIndex={0}
onKeyDown={onEnter(() =>
onClick(firstFilterOption(filter))
)}
onClick={() => onClick={() =>
onClick(firstFilterOption(filter)) onClick(firstFilterOption(filter))
} }
@ -71,6 +79,10 @@ export const SearchInstructions: VFC<ISearchInstructionsProps> = ({
<> <>
{' or '} {' or '}
<StyledCode <StyledCode
tabIndex={0}
onKeyDown={onEnter(() =>
onClick(secondFilterOption(filter))
)}
onClick={() => { onClick={() => {
onClick(secondFilterOption(filter)); 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', () => { test('displays search and filter instructions when no search value is provided', () => {
let recordedSuggestion = ''; let recordedSuggestion = '';
render( render(
@ -106,3 +136,22 @@ test('displays search and filter instructions when filter value is provided', ()
screen.getByText(/Title A/i).click(); screen.getByText(/Title A/i).click();
expect(recordedSuggestion).toBe('environment:"dev env" Title A'); 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, StyledCode,
} from './SearchInstructions/SearchInstructions'; } from './SearchInstructions/SearchInstructions';
import { usePlausibleTracker } from 'hooks/usePlausibleTracker'; import { usePlausibleTracker } from 'hooks/usePlausibleTracker';
import { onEnter } from './onEnter';
const StyledPaper = styled(Paper)(({ theme }) => ({ const StyledPaper = styled(Paper)(({ theme }) => ({
position: 'absolute', position: 'absolute',
@ -103,9 +104,37 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
? getColumnValues(searchableColumns[0], searchContext.data[0]) ? getColumnValues(searchableColumns[0], searchContext.data[0])
: 'example-search-text'; : 'example-search-text';
const selectedFilter = filters.map( const selectedFilter =
filter => `${filter.name}:${filter.suggestedOption}` filters.length === 0
)[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 ( return (
<StyledPaper className="dropdown-outline"> <StyledPaper className="dropdown-outline">
@ -116,14 +145,9 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
<StyledBox> <StyledBox>
<StyledHistory /> <StyledHistory />
<StyledCode <StyledCode
onClick={() => { tabIndex={0}
onSuggestion(savedQuery || ''); onClick={onSavedQuery}
trackEvent('search-filter-suggestions', { onKeyDown={onEnter(onSavedQuery)}
props: {
eventType: 'saved query',
},
});
}}
> >
<span>{savedQuery}</span> <span>{savedQuery}</span>
</StyledCode> </StyledCode>
@ -153,14 +177,7 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
searchableColumnsString={ searchableColumnsString={
searchableColumnsString searchableColumnsString
} }
onClick={suggestion => { onClick={onFilter}
onSuggestion(suggestion);
trackEvent('search-filter-suggestions', {
props: {
eventType: 'filter',
},
});
}}
/> />
} }
/> />
@ -173,16 +190,9 @@ export const SearchSuggestions: VFC<SearchSuggestionsProps> = ({
show="Combine filters and search: " show="Combine filters and search: "
/> />
<StyledCode <StyledCode
onClick={() => { tabIndex={0}
onSuggestion( onClick={onSearchAndFilter}
selectedFilter + ' ' + suggestedTextSearch onKeyDown={onEnter(onSearchAndFilter)}
);
trackEvent('search-filter-suggestions', {
props: {
eventType: 'search and filter',
},
});
}}
> >
<span key={selectedFilter}>{selectedFilter}</span>{' '} <span key={selectedFilter}>{selectedFilter}</span>{' '}
<span>{suggestedTextSearch}</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]);
};