mirror of
https://github.com/Unleash/unleash.git
synced 2025-06-09 01:17:06 +02:00
feat: Search suggestion selectable (#4610)
This commit is contained in:
parent
47a59224bb
commit
41858a4952
@ -7,6 +7,7 @@ import { SearchSuggestions } from './SearchSuggestions/SearchSuggestions';
|
|||||||
import { IGetSearchContextOutput } from 'hooks/useSearch';
|
import { IGetSearchContextOutput } from 'hooks/useSearch';
|
||||||
import { useKeyboardShortcut } from 'hooks/useKeyboardShortcut';
|
import { useKeyboardShortcut } from 'hooks/useKeyboardShortcut';
|
||||||
import { SEARCH_INPUT } from 'utils/testIds';
|
import { SEARCH_INPUT } from 'utils/testIds';
|
||||||
|
import { useOnClickOutside } from 'hooks/useOnClickOutside';
|
||||||
|
|
||||||
interface ISearchProps {
|
interface ISearchProps {
|
||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
@ -69,8 +70,10 @@ export const Search = ({
|
|||||||
containerStyles,
|
containerStyles,
|
||||||
debounceTime = 200,
|
debounceTime = 200,
|
||||||
}: ISearchProps) => {
|
}: ISearchProps) => {
|
||||||
const ref = useRef<HTMLInputElement>();
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const suggestionsRef = useRef<HTMLInputElement>(null);
|
||||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||||
|
const hideSuggestions = () => setShowSuggestions(false);
|
||||||
|
|
||||||
const [value, setValue] = useState(initialValue);
|
const [value, setValue] = useState(initialValue);
|
||||||
const debouncedOnChange = useAsyncDebounce(onChange, debounceTime);
|
const debouncedOnChange = useAsyncDebounce(onChange, debounceTime);
|
||||||
@ -83,20 +86,23 @@ export const Search = ({
|
|||||||
const hotkey = useKeyboardShortcut(
|
const hotkey = useKeyboardShortcut(
|
||||||
{ modifiers: ['ctrl'], key: 'k', preventDefault: true },
|
{ modifiers: ['ctrl'], key: 'k', preventDefault: true },
|
||||||
() => {
|
() => {
|
||||||
if (document.activeElement === ref.current) {
|
if (document.activeElement === searchInputRef.current) {
|
||||||
ref.current?.blur();
|
searchInputRef.current?.blur();
|
||||||
} else {
|
} else {
|
||||||
ref.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
useKeyboardShortcut({ key: 'Escape' }, () => {
|
useKeyboardShortcut({ key: 'Escape' }, () => {
|
||||||
if (document.activeElement === ref.current) {
|
if (document.activeElement === searchInputRef.current) {
|
||||||
ref.current?.blur();
|
searchInputRef.current?.blur();
|
||||||
|
hideSuggestions();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const placeholder = `${customPlaceholder ?? 'Search'} (${hotkey})`;
|
const placeholder = `${customPlaceholder ?? 'Search'} (${hotkey})`;
|
||||||
|
|
||||||
|
useOnClickOutside([searchInputRef, suggestionsRef], hideSuggestions);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<StyledContainer style={containerStyles}>
|
<StyledContainer style={containerStyles}>
|
||||||
<StyledSearch className={className}>
|
<StyledSearch className={className}>
|
||||||
@ -107,7 +113,7 @@ export const Search = ({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<StyledInputBase
|
<StyledInputBase
|
||||||
inputRef={ref}
|
inputRef={searchInputRef}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
inputProps={{
|
inputProps={{
|
||||||
'aria-label': placeholder,
|
'aria-label': placeholder,
|
||||||
@ -116,7 +122,6 @@ export const Search = ({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={e => onSearchChange(e.target.value)}
|
onChange={e => onSearchChange(e.target.value)}
|
||||||
onFocus={() => setShowSuggestions(true)}
|
onFocus={() => setShowSuggestions(true)}
|
||||||
onBlur={() => setShowSuggestions(false)}
|
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
<Box sx={{ width: theme => theme.spacing(4) }}>
|
<Box sx={{ width: theme => theme.spacing(4) }}>
|
||||||
@ -128,7 +133,7 @@ export const Search = ({
|
|||||||
size="small"
|
size="small"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onSearchChange('');
|
onSearchChange('');
|
||||||
ref.current?.focus();
|
searchInputRef.current?.focus();
|
||||||
}}
|
}}
|
||||||
sx={{ padding: theme => theme.spacing(1) }}
|
sx={{ padding: theme => theme.spacing(1) }}
|
||||||
>
|
>
|
||||||
@ -142,7 +147,11 @@ export const Search = ({
|
|||||||
<ConditionallyRender
|
<ConditionallyRender
|
||||||
condition={Boolean(hasFilters) && showSuggestions}
|
condition={Boolean(hasFilters) && showSuggestions}
|
||||||
show={
|
show={
|
||||||
<SearchSuggestions getSearchContext={getSearchContext!} />
|
<div ref={suggestionsRef}>
|
||||||
|
<SearchSuggestions
|
||||||
|
getSearchContext={getSearchContext!}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</StyledContainer>
|
</StyledContainer>
|
||||||
|
44
frontend/src/hooks/useOnClickOutside.test.tsx
Normal file
44
frontend/src/hooks/useOnClickOutside.test.tsx
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { fireEvent, render, screen } from '@testing-library/react';
|
||||||
|
import { useRef } from 'react';
|
||||||
|
import { useOnClickOutside } from './useOnClickOutside';
|
||||||
|
|
||||||
|
function TestComponent(props: { outsideClickHandler: () => void }) {
|
||||||
|
const divRef = useRef(null);
|
||||||
|
useOnClickOutside([divRef], props.outsideClickHandler);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="wrapper">
|
||||||
|
<div data-testid="inside" ref={divRef}>
|
||||||
|
Inside
|
||||||
|
</div>
|
||||||
|
<div data-testid="outside">Outside</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test('should not call the callback when clicking inside', () => {
|
||||||
|
let mockCallbackCallCount = 0;
|
||||||
|
const mockCallback = () => mockCallbackCallCount++;
|
||||||
|
|
||||||
|
render(<TestComponent outsideClickHandler={mockCallback} />);
|
||||||
|
|
||||||
|
const insideDiv = screen.getByTestId('inside');
|
||||||
|
|
||||||
|
// Simulate a click inside the div
|
||||||
|
fireEvent.click(insideDiv);
|
||||||
|
|
||||||
|
expect(mockCallbackCallCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should call the callback when clicking outside', () => {
|
||||||
|
let mockCallbackCallCount = 0;
|
||||||
|
const mockCallback = () => mockCallbackCallCount++;
|
||||||
|
|
||||||
|
render(<TestComponent outsideClickHandler={mockCallback} />);
|
||||||
|
|
||||||
|
const outsideDiv = screen.getByTestId('outside');
|
||||||
|
|
||||||
|
fireEvent.click(outsideDiv);
|
||||||
|
|
||||||
|
expect(mockCallbackCallCount).toBe(1);
|
||||||
|
});
|
33
frontend/src/hooks/useOnClickOutside.ts
Normal file
33
frontend/src/hooks/useOnClickOutside.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to handle outside clicks for a given list of refs.
|
||||||
|
*
|
||||||
|
* @param {Array<React.RefObject>} refs - List of refs to the target elements.
|
||||||
|
* @param {Function} callback - Callback to execute on outside click.
|
||||||
|
*/
|
||||||
|
export const useOnClickOutside = (
|
||||||
|
refs: Array<React.RefObject<HTMLElement>>,
|
||||||
|
callback: Function
|
||||||
|
) => {
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
// Check if event target is outside all provided refs
|
||||||
|
if (
|
||||||
|
!refs.some(
|
||||||
|
ref =>
|
||||||
|
ref.current &&
|
||||||
|
ref.current.contains(event.target as Node)
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('click', handleClickOutside);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('click', handleClickOutside);
|
||||||
|
};
|
||||||
|
}, [refs, callback]);
|
||||||
|
};
|
Loading…
Reference in New Issue
Block a user