feat(search-react): create search bar autocomplete
Signed-off-by: Camila Belo <camilaibs@gmail.com>
This commit is contained in:
@@ -0,0 +1,233 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import LabelIcon from '@material-ui/icons/Label';
|
||||
|
||||
import { configApiRef } from '@backstage/core-plugin-api';
|
||||
import { ConfigReader } from '@backstage/core-app-api';
|
||||
import { TestApiProvider, renderWithEffects } from '@backstage/test-utils';
|
||||
|
||||
import { searchApiRef } from '../../api';
|
||||
import {
|
||||
SearchAutocomplete,
|
||||
SearchAutocompleteDefaultOption,
|
||||
} from './SearchAutocomplete';
|
||||
|
||||
const title = 'Backstage Test App';
|
||||
const configApiMock = new ConfigReader({
|
||||
app: { title },
|
||||
});
|
||||
|
||||
const query = jest.fn().mockResolvedValue({ results: [] });
|
||||
const searchApiMock = { query };
|
||||
|
||||
describe('SearchAutocomplete', () => {
|
||||
const options = ['hello-world', 'petstore', 'spotify'];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('Renders without exploding', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete options={options} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('search-autocomplete')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('Show all options by default when focused', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete options={options} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(options[0])).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(options[1])).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(options[2])).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByPlaceholderText(`Search in ${title}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(options[0])).toBeInTheDocument();
|
||||
expect(screen.getByText(options[1])).toBeInTheDocument();
|
||||
expect(screen.getByText(options[2])).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Updates context with the initial value', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete value={options[0]} options={options} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toBeCalledWith({
|
||||
filters: {},
|
||||
pageCursor: undefined,
|
||||
term: options[0],
|
||||
types: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Updates context when value is cleared', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete value={options[0]} options={options} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toBeCalledWith({
|
||||
filters: {},
|
||||
pageCursor: undefined,
|
||||
term: options[0],
|
||||
types: [],
|
||||
});
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByLabelText('Clear'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toBeCalledWith({
|
||||
filters: {},
|
||||
pageCursor: undefined,
|
||||
term: '',
|
||||
types: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Updates context when an option is select', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete options={options} />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByPlaceholderText(`Search in ${title}`));
|
||||
|
||||
await userEvent.click(screen.getByText(options[0]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(query).toBeCalledWith({
|
||||
filters: {},
|
||||
pageCursor: undefined,
|
||||
term: options[0],
|
||||
types: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('Shows a circular progress when loading options', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete options={options} loading />
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByTestId('search-autocomplete-progressbar'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('Uses the default search autocomplete option component', async () => {
|
||||
await renderWithEffects(
|
||||
<TestApiProvider
|
||||
apis={[
|
||||
[configApiRef, configApiMock],
|
||||
[searchApiRef, searchApiMock],
|
||||
]}
|
||||
>
|
||||
<SearchAutocomplete
|
||||
options={[
|
||||
{
|
||||
title: 'hello-world',
|
||||
text: 'Hello World example for gRPC',
|
||||
},
|
||||
{
|
||||
title: 'petstore',
|
||||
text: 'The petstore API',
|
||||
},
|
||||
{
|
||||
title: 'spotify',
|
||||
text: 'The Spotify web API',
|
||||
},
|
||||
]}
|
||||
getOptionLabel={option => option.title}
|
||||
renderOption={option => (
|
||||
<SearchAutocompleteDefaultOption
|
||||
icon={<LabelIcon titleAccess="Option icon" />}
|
||||
primaryText={option.title}
|
||||
secondaryText={option.text}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</TestApiProvider>,
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByPlaceholderText(`Search in ${title}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByTitle('Option icon')).toHaveLength(3);
|
||||
expect(screen.getByText('hello-world')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Hello World example for gRPC'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ChangeEvent, ReactNode, useCallback, useMemo } from 'react';
|
||||
|
||||
import {
|
||||
CircularProgress,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
ListItemTextProps,
|
||||
} from '@material-ui/core';
|
||||
import {
|
||||
Value,
|
||||
Autocomplete,
|
||||
AutocompleteProps,
|
||||
AutocompleteChangeDetails,
|
||||
AutocompleteChangeReason,
|
||||
AutocompleteRenderInputParams,
|
||||
} from '@material-ui/lab';
|
||||
|
||||
import { SearchContextProvider, useSearch } from '../../context';
|
||||
import { SearchBar, SearchBarProps } from '../SearchBar';
|
||||
|
||||
/**
|
||||
* Props for {@link SearchAutocomplete}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchAutocompleteProps<
|
||||
T,
|
||||
Multiple extends boolean | undefined,
|
||||
DisableClearable extends boolean | undefined,
|
||||
FreeSolo extends boolean | undefined,
|
||||
> = Omit<
|
||||
AutocompleteProps<T, Multiple, DisableClearable, FreeSolo>,
|
||||
'renderInput'
|
||||
> & {
|
||||
inputDebounceTime?: SearchBarProps['debounceTime'];
|
||||
renderInput?: (params: AutocompleteRenderInputParams) => JSX.Element;
|
||||
};
|
||||
|
||||
const withContext = (
|
||||
Component: <
|
||||
T,
|
||||
Multiple extends boolean | undefined,
|
||||
DisableClearable extends boolean | undefined,
|
||||
FreeSolo extends boolean | undefined,
|
||||
>(
|
||||
props: SearchAutocompleteProps<T, Multiple, DisableClearable, FreeSolo>,
|
||||
) => JSX.Element,
|
||||
) => {
|
||||
return <
|
||||
T,
|
||||
Multiple extends boolean | undefined,
|
||||
DisableClearable extends boolean | undefined,
|
||||
FreeSolo extends boolean | undefined,
|
||||
>(
|
||||
props: SearchAutocompleteProps<T, Multiple, DisableClearable, FreeSolo>,
|
||||
) => (
|
||||
<SearchContextProvider useParentContext>
|
||||
<Component {...props} />
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Recommended search autocomplete when you use the Search Provider or Search Context.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchAutocomplete = withContext(
|
||||
<
|
||||
T,
|
||||
Multiple extends boolean | undefined,
|
||||
DisableClearable extends boolean | undefined,
|
||||
FreeSolo extends boolean | undefined,
|
||||
>({
|
||||
loading,
|
||||
value,
|
||||
onChange = () => {},
|
||||
options = [],
|
||||
getOptionLabel = (option: T) => String(option),
|
||||
renderInput,
|
||||
inputDebounceTime,
|
||||
fullWidth = true,
|
||||
clearOnBlur = false,
|
||||
...rest
|
||||
}: SearchAutocompleteProps<T, Multiple, DisableClearable, FreeSolo>) => {
|
||||
const { setTerm } = useSearch();
|
||||
|
||||
const inputValue = useMemo(() => {
|
||||
return value ? getOptionLabel(value as T) : '';
|
||||
}, [value, getOptionLabel]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(
|
||||
event: ChangeEvent<{}>,
|
||||
newValue: Value<T, Multiple, DisableClearable, FreeSolo>,
|
||||
reason: AutocompleteChangeReason,
|
||||
details?: AutocompleteChangeDetails<T>,
|
||||
) => {
|
||||
onChange(event, newValue, reason, details);
|
||||
setTerm(newValue ? getOptionLabel(newValue as T) : '');
|
||||
},
|
||||
[getOptionLabel, setTerm, onChange],
|
||||
);
|
||||
|
||||
const defaultRenderInput = useCallback(
|
||||
({
|
||||
InputProps: { ref, endAdornment },
|
||||
InputLabelProps,
|
||||
...params
|
||||
}: AutocompleteRenderInputParams) => (
|
||||
<SearchBar
|
||||
{...params}
|
||||
ref={ref}
|
||||
clearButton={false}
|
||||
value={inputValue}
|
||||
debounceTime={inputDebounceTime}
|
||||
endAdornment={
|
||||
loading ? (
|
||||
<CircularProgress
|
||||
data-testid="search-autocomplete-progressbar"
|
||||
color="inherit"
|
||||
size={20}
|
||||
/>
|
||||
) : (
|
||||
endAdornment
|
||||
)
|
||||
}
|
||||
/>
|
||||
),
|
||||
[loading, inputValue, inputDebounceTime],
|
||||
);
|
||||
|
||||
return (
|
||||
<Autocomplete
|
||||
data-testid="search-autocomplete"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
options={options}
|
||||
getOptionLabel={getOptionLabel}
|
||||
renderInput={renderInput ?? defaultRenderInput}
|
||||
fullWidth={fullWidth}
|
||||
clearOnBlur={clearOnBlur}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for {@link SearchAutocompleteDefaultOption}.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export type SearchAutocompleteDefaultOptionProps = {
|
||||
icon?: ReactNode;
|
||||
primaryText: ListItemTextProps['primary'];
|
||||
primaryTextTypographyProps?: ListItemTextProps['primaryTypographyProps'];
|
||||
secondaryText?: ListItemTextProps['secondary'];
|
||||
secondaryTextTypographyProps?: ListItemTextProps['secondaryTypographyProps'];
|
||||
disableTextTypography?: ListItemTextProps['disableTypography'];
|
||||
};
|
||||
|
||||
/**
|
||||
* A default search bar autocomplete component.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchAutocompleteDefaultOption = ({
|
||||
icon,
|
||||
primaryText,
|
||||
primaryTextTypographyProps,
|
||||
secondaryText,
|
||||
secondaryTextTypographyProps,
|
||||
disableTextTypography,
|
||||
}: SearchAutocompleteDefaultOptionProps) => (
|
||||
<>
|
||||
{icon ? <ListItemIcon>{icon}</ListItemIcon> : null}
|
||||
<ListItemText
|
||||
primary={primaryText}
|
||||
primaryTypographyProps={primaryTextTypographyProps}
|
||||
secondary={secondaryText}
|
||||
secondaryTypographyProps={secondaryTextTypographyProps}
|
||||
disableTypography={disableTextTypography}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* Copyright 2022 The Backstage Authors
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export {
|
||||
SearchAutocomplete,
|
||||
SearchAutocompleteDefaultOption,
|
||||
} from './SearchAutocomplete';
|
||||
|
||||
export type {
|
||||
SearchAutocompleteProps,
|
||||
SearchAutocompleteDefaultOptionProps,
|
||||
} from './SearchAutocomplete';
|
||||
@@ -20,8 +20,11 @@ import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
ComponentType,
|
||||
} from 'react';
|
||||
import useDebounce from 'react-use/lib/useDebounce';
|
||||
|
||||
import {
|
||||
InputBase,
|
||||
InputBaseProps,
|
||||
@@ -37,11 +40,7 @@ import {
|
||||
useApi,
|
||||
} from '@backstage/core-plugin-api';
|
||||
|
||||
import {
|
||||
SearchContextProvider,
|
||||
useSearch,
|
||||
useSearchContextCheck,
|
||||
} from '../../context';
|
||||
import { SearchContextProvider, useSearch } from '../../context';
|
||||
import { TrackSearch } from '../SearchTracker';
|
||||
|
||||
/**
|
||||
@@ -64,94 +63,97 @@ export type SearchBarBaseProps = Omit<InputBaseProps, 'onChange'> & {
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchBarBase = ({
|
||||
onChange,
|
||||
onKeyDown,
|
||||
onSubmit,
|
||||
debounceTime = 200,
|
||||
clearButton = true,
|
||||
fullWidth = true,
|
||||
value: defaultValue,
|
||||
inputProps: defaultInputProps = {},
|
||||
endAdornment: defaultEndAdornment,
|
||||
...props
|
||||
}: SearchBarBaseProps) => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const [value, setValue] = useState<string>(defaultValue as string);
|
||||
const hasSearchContext = useSearchContextCheck();
|
||||
export const SearchBarBase = forwardRef<unknown, SearchBarBaseProps>(
|
||||
(
|
||||
{
|
||||
onChange,
|
||||
onKeyDown = () => {},
|
||||
onClear = () => {},
|
||||
onSubmit = () => {},
|
||||
debounceTime = 200,
|
||||
clearButton = true,
|
||||
fullWidth = true,
|
||||
value: defaultValue,
|
||||
inputProps: defaultInputProps = {},
|
||||
endAdornment: defaultEndAdornment,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const configApi = useApi(configApiRef);
|
||||
const [value, setValue] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setValue(prevValue =>
|
||||
prevValue !== defaultValue ? (defaultValue as string) : prevValue,
|
||||
useEffect(() => {
|
||||
setValue(prevValue =>
|
||||
prevValue !== defaultValue ? String(defaultValue) : prevValue,
|
||||
);
|
||||
}, [defaultValue]);
|
||||
|
||||
useDebounce(() => onChange(value), debounceTime, [value]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
}, [defaultValue]);
|
||||
|
||||
useDebounce(() => onChange(value), debounceTime, [value]);
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
if (onSubmit && e.key === 'Enter') {
|
||||
onSubmit();
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmit],
|
||||
);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(e: ChangeEvent<HTMLInputElement>) => {
|
||||
setValue(e.target.value);
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (onKeyDown) onKeyDown(e);
|
||||
if (onSubmit && e.key === 'Enter') {
|
||||
onSubmit();
|
||||
const handleClear = useCallback(() => {
|
||||
onChange('');
|
||||
if (onClear) {
|
||||
onClear();
|
||||
}
|
||||
},
|
||||
[onKeyDown, onSubmit],
|
||||
);
|
||||
}, [onChange, onClear]);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
onChange('');
|
||||
}, [onChange]);
|
||||
const placeholder = `Search in ${
|
||||
configApi.getOptionalString('app.title') || 'Backstage'
|
||||
}`;
|
||||
|
||||
const placeholder = `Search in ${
|
||||
configApi.getOptionalString('app.title') || 'Backstage'
|
||||
}`;
|
||||
const startAdornment = (
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query" size="small" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
const startAdornment = (
|
||||
<InputAdornment position="start">
|
||||
<IconButton aria-label="Query" disabled>
|
||||
<SearchIcon />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
const endAdornment = (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Clear" size="small" onClick={handleClear}>
|
||||
<ClearButton />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
const endAdornment = (
|
||||
<InputAdornment position="end">
|
||||
<IconButton aria-label="Clear" onClick={handleClear}>
|
||||
<ClearButton />
|
||||
</IconButton>
|
||||
</InputAdornment>
|
||||
);
|
||||
|
||||
const searchBar = (
|
||||
<TrackSearch>
|
||||
<InputBase
|
||||
data-testid="search-bar-next"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
startAdornment={startAdornment}
|
||||
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
|
||||
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
|
||||
fullWidth={fullWidth}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
</TrackSearch>
|
||||
);
|
||||
|
||||
return hasSearchContext ? (
|
||||
searchBar
|
||||
) : (
|
||||
<SearchContextProvider>{searchBar}</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<TrackSearch>
|
||||
<InputBase
|
||||
data-testid="search-bar-next"
|
||||
ref={ref}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
startAdornment={startAdornment}
|
||||
endAdornment={clearButton ? endAdornment : defaultEndAdornment}
|
||||
inputProps={{ 'aria-label': 'Search', ...defaultInputProps }}
|
||||
fullWidth={fullWidth}
|
||||
onChange={handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
</TrackSearch>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Props for {@link SearchBar}.
|
||||
@@ -160,30 +162,53 @@ export const SearchBarBase = ({
|
||||
*/
|
||||
export type SearchBarProps = Partial<SearchBarBaseProps>;
|
||||
|
||||
const withContext = (Component: ComponentType<SearchBarProps>) => {
|
||||
return forwardRef<unknown, SearchBarProps>((props, ref) => (
|
||||
<SearchContextProvider useParentContext>
|
||||
<Component {...props} ref={ref} />
|
||||
</SearchContextProvider>
|
||||
));
|
||||
};
|
||||
|
||||
/**
|
||||
* Recommended search bar when you use the Search Provider or Search Context.
|
||||
*
|
||||
* @public
|
||||
*/
|
||||
export const SearchBar = ({ onChange, ...props }: SearchBarProps) => {
|
||||
const { term, setTerm } = useSearch();
|
||||
export const SearchBar = withContext(
|
||||
forwardRef<unknown, SearchBarProps>(
|
||||
({ value: initialValue = '', onChange, ...rest }, ref) => {
|
||||
const { term, setTerm } = useSearch();
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
} else {
|
||||
setTerm(newValue);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (initialValue) {
|
||||
setTerm(String(initialValue));
|
||||
}
|
||||
}, [initialValue, setTerm]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(newValue: string) => {
|
||||
if (onChange) {
|
||||
onChange(newValue);
|
||||
} else {
|
||||
setTerm(newValue);
|
||||
}
|
||||
},
|
||||
[onChange, setTerm],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsContext
|
||||
attributes={{ pluginId: 'search', extension: 'SearchBar' }}
|
||||
>
|
||||
<SearchBarBase
|
||||
{...rest}
|
||||
ref={ref}
|
||||
value={term}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</AnalyticsContext>
|
||||
);
|
||||
},
|
||||
[onChange, setTerm],
|
||||
);
|
||||
|
||||
return (
|
||||
<AnalyticsContext
|
||||
attributes={{ pluginId: 'search', extension: 'SearchBar' }}
|
||||
>
|
||||
<SearchBarBase value={term} onChange={handleChange} {...props} />
|
||||
</AnalyticsContext>
|
||||
);
|
||||
};
|
||||
),
|
||||
);
|
||||
|
||||
@@ -15,4 +15,5 @@
|
||||
*/
|
||||
|
||||
export { SearchBar, SearchBarBase } from './SearchBar';
|
||||
|
||||
export type { SearchBarProps, SearchBarBaseProps } from './SearchBar';
|
||||
|
||||
@@ -15,8 +15,9 @@
|
||||
*/
|
||||
|
||||
export * from './HighlightedSearchResultText';
|
||||
export * from './SearchBar';
|
||||
export * from './SearchAutocomplete';
|
||||
export * from './SearchFilter';
|
||||
export * from './SearchResult';
|
||||
export * from './SearchResultPager';
|
||||
export * from './SearchBar';
|
||||
export * from './DefaultResultListItem';
|
||||
|
||||
Reference in New Issue
Block a user