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:
parent
77fbac01e4
commit
ba73d9a0d1
@ -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
|
||||
|
@ -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));
|
||||
}}
|
||||
|
@ -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');
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -0,0 +1,7 @@
|
||||
export const onEnter = (callback: () => void) => {
|
||||
return (event: React.KeyboardEvent<HTMLSpanElement>): void => {
|
||||
if (event.key === 'Enter' || event.keyCode === 13) {
|
||||
callback();
|
||||
}
|
||||
};
|
||||
};
|
52
frontend/src/hooks/useOnBlur.test.tsx
Normal file
52
frontend/src/hooks/useOnBlur.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
31
frontend/src/hooks/useOnBlur.ts
Normal file
31
frontend/src/hooks/useOnBlur.ts
Normal 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]);
|
||||
};
|
Loading…
Reference in New Issue
Block a user