diff --git a/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx
new file mode 100644
index 0000000000..4502a61d6e
--- /dev/null
+++ b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.test.tsx
@@ -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(
+
+
+ ,
+ );
+
+ expect(screen.getByTestId('search-autocomplete')).toBeInTheDocument();
+ });
+
+ it('Show all options by default when focused', async () => {
+ await renderWithEffects(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(query).toBeCalledWith({
+ filters: {},
+ pageCursor: undefined,
+ term: options[0],
+ types: [],
+ });
+ });
+ });
+
+ it('Updates context when value is cleared', async () => {
+ await renderWithEffects(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ 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(
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(
+ screen.getByTestId('search-autocomplete-progressbar'),
+ ).toBeInTheDocument();
+ });
+ });
+
+ it('Uses the default search autocomplete option component', async () => {
+ await renderWithEffects(
+
+ option.title}
+ renderOption={option => (
+ }
+ primaryText={option.title}
+ secondaryText={option.text}
+ />
+ )}
+ />
+ ,
+ );
+
+ 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();
+ });
+ });
+});
diff --git a/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.tsx b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.tsx
new file mode 100644
index 0000000000..1068fefa50
--- /dev/null
+++ b/plugins/search-react/src/components/SearchAutocomplete/SearchAutocomplete.tsx
@@ -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,
+ '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,
+ ) => JSX.Element,
+) => {
+ return <
+ T,
+ Multiple extends boolean | undefined,
+ DisableClearable extends boolean | undefined,
+ FreeSolo extends boolean | undefined,
+ >(
+ props: SearchAutocompleteProps,
+ ) => (
+
+
+
+ );
+};
+
+/**
+ * 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) => {
+ const { setTerm } = useSearch();
+
+ const inputValue = useMemo(() => {
+ return value ? getOptionLabel(value as T) : '';
+ }, [value, getOptionLabel]);
+
+ const handleChange = useCallback(
+ (
+ event: ChangeEvent<{}>,
+ newValue: Value,
+ reason: AutocompleteChangeReason,
+ details?: AutocompleteChangeDetails,
+ ) => {
+ onChange(event, newValue, reason, details);
+ setTerm(newValue ? getOptionLabel(newValue as T) : '');
+ },
+ [getOptionLabel, setTerm, onChange],
+ );
+
+ const defaultRenderInput = useCallback(
+ ({
+ InputProps: { ref, endAdornment },
+ InputLabelProps,
+ ...params
+ }: AutocompleteRenderInputParams) => (
+
+ ) : (
+ endAdornment
+ )
+ }
+ />
+ ),
+ [loading, inputValue, inputDebounceTime],
+ );
+
+ return (
+
+ );
+ },
+);
+
+/**
+ * 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 ? {icon} : null}
+
+ >
+);
diff --git a/plugins/search-react/src/components/SearchAutocomplete/index.ts b/plugins/search-react/src/components/SearchAutocomplete/index.ts
new file mode 100644
index 0000000000..c5534cef33
--- /dev/null
+++ b/plugins/search-react/src/components/SearchAutocomplete/index.ts
@@ -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';
diff --git a/plugins/search-react/src/components/SearchBar/SearchBar.tsx b/plugins/search-react/src/components/SearchBar/SearchBar.tsx
index d3d25455ce..9e91f3f965 100644
--- a/plugins/search-react/src/components/SearchBar/SearchBar.tsx
+++ b/plugins/search-react/src/components/SearchBar/SearchBar.tsx
@@ -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 & {
*
* @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(defaultValue as string);
- const hasSearchContext = useSearchContextCheck();
+export const SearchBarBase = forwardRef(
+ (
+ {
+ 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('');
- 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) => {
+ setValue(e.target.value);
+ },
+ [setValue],
);
- }, [defaultValue]);
- useDebounce(() => onChange(value), debounceTime, [value]);
+ const handleKeyDown = useCallback(
+ (e: KeyboardEvent) => {
+ if (onKeyDown) onKeyDown(e);
+ if (onSubmit && e.key === 'Enter') {
+ onSubmit();
+ }
+ },
+ [onKeyDown, onSubmit],
+ );
- const handleChange = useCallback(
- (e: ChangeEvent) => {
- setValue(e.target.value);
- },
- [setValue],
- );
-
- const handleKeyDown = useCallback(
- (e: KeyboardEvent) => {
- 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 = (
+
+
+
+
+
+ );
- const startAdornment = (
-
-
-
-
-
- );
+ const endAdornment = (
+
+
+
+
+
+ );
- const endAdornment = (
-
-
-
-
-
- );
-
- const searchBar = (
-
-
-
- );
-
- return hasSearchContext ? (
- searchBar
- ) : (
- {searchBar}
- );
-};
+ return (
+
+
+
+ );
+ },
+);
/**
* Props for {@link SearchBar}.
@@ -160,30 +162,53 @@ export const SearchBarBase = ({
*/
export type SearchBarProps = Partial;
+const withContext = (Component: ComponentType) => {
+ return forwardRef((props, ref) => (
+
+
+
+ ));
+};
+
/**
* 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(
+ ({ 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 (
+
+
+
+ );
},
- [onChange, setTerm],
- );
-
- return (
-
-
-
- );
-};
+ ),
+);
diff --git a/plugins/search-react/src/components/SearchBar/index.tsx b/plugins/search-react/src/components/SearchBar/index.tsx
index 075a0c7dc2..928543916a 100644
--- a/plugins/search-react/src/components/SearchBar/index.tsx
+++ b/plugins/search-react/src/components/SearchBar/index.tsx
@@ -15,4 +15,5 @@
*/
export { SearchBar, SearchBarBase } from './SearchBar';
+
export type { SearchBarProps, SearchBarBaseProps } from './SearchBar';
diff --git a/plugins/search-react/src/components/index.ts b/plugins/search-react/src/components/index.ts
index 263ca4ad59..e254c36dd9 100644
--- a/plugins/search-react/src/components/index.ts
+++ b/plugins/search-react/src/components/index.ts
@@ -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';