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:
parent
77fbac01e4
commit
ba73d9a0d1
@ -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
|
||||||
|
@ -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));
|
||||||
}}
|
}}
|
||||||
|
@ -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');
|
||||||
|
});
|
||||||
|
@ -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>
|
||||||
|
@ -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