feat(ui): add SearchAutocomplete component

Add SearchAutocomplete and SearchAutocompleteItem components for
building accessible search-with-results patterns. Built on React
Aria's Autocomplete with virtual focus for keyboard navigation
and a non-modal popover for results.

Features:
- Controlled input via inputValue/onInputChange
- Configurable popover width and placement
- Rich content support per result item
- Item selection via onAction
- defaultOpen prop for visual testing
- Close on interact outside and input clear

Includes Storybook stories, docs-ui documentation, changeset,
and API report.

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-03-13 17:21:28 +01:00
parent 5e80c4343f
commit d9d2dd6827
16 changed files with 1645 additions and 0 deletions
@@ -0,0 +1,156 @@
'use client';
import { useState } from 'react';
import {
SearchAutocomplete,
SearchAutocompleteItem,
} from '../../../../../packages/ui/src/components/SearchAutocomplete/SearchAutocomplete';
import { PluginHeader } from '../../../../../packages/ui/src/components/PluginHeader/PluginHeader';
import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex';
import { Text } from '../../../../../packages/ui/src/components/Text/Text';
import { MemoryRouter } from 'react-router-dom';
const fruits = [
{ id: 'apple', name: 'Apple', description: 'A round fruit' },
{ id: 'banana', name: 'Banana', description: 'A yellow curved fruit' },
{ id: 'blueberry', name: 'Blueberry', description: 'A small blue berry' },
{ id: 'cherry', name: 'Cherry', description: 'A small red stone fruit' },
{ id: 'grape', name: 'Grape', description: 'Grows in clusters on vines' },
{ id: 'lemon', name: 'Lemon', description: 'A sour yellow citrus fruit' },
{ id: 'mango', name: 'Mango', description: 'A tropical stone fruit' },
{ id: 'orange', name: 'Orange', description: 'A citrus fruit' },
{ id: 'peach', name: 'Peach', description: 'A fuzzy stone fruit' },
{
id: 'strawberry',
name: 'Strawberry',
description: 'A red fruit with seeds on its surface',
},
];
export const WithItems = () => {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<SearchAutocomplete
placeholder="Search fruits..."
inputValue={inputValue}
onInputChange={setInputValue}
style={{ maxWidth: '300px' }}
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
>
{fruit.name}
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
);
};
export const WithRichContent = () => {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<SearchAutocomplete
placeholder="Search fruits..."
inputValue={inputValue}
onInputChange={setInputValue}
style={{ maxWidth: '300px' }}
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
>
<Text weight="bold">{fruit.name}</Text>
<Text variant="body-small" color="secondary">
{fruit.description}
</Text>
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
);
};
export const InHeader = () => {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<MemoryRouter>
<PluginHeader
title="Title"
customActions={
<>
<SearchAutocomplete
size="small"
inputValue={inputValue}
onInputChange={setInputValue}
popoverWidth="400px"
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
>
{fruit.name}
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
</>
}
/>
</MemoryRouter>
);
};
export const WithSelection = () => {
const [inputValue, setInputValue] = useState('');
const [selected, setSelected] = useState<string | null>(null);
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<Flex direction="column" gap="4">
<SearchAutocomplete
placeholder="Search fruits..."
inputValue={inputValue}
onInputChange={setInputValue}
style={{ maxWidth: '300px' }}
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
onAction={() => {
setSelected(fruit.name);
setInputValue('');
}}
>
{fruit.name}
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
<Text>Last selected: {selected ?? 'none'}</Text>
</Flex>
);
};
@@ -0,0 +1,95 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { ReactAriaLink } from '@/components/ReactAriaLink';
import {
searchAutocompletePropDefs,
searchAutocompleteItemPropDefs,
} from './props-definition';
import {
usage,
defaultSnippet,
withRichContent,
inHeader,
withSelection,
} from './snippets';
import {
WithItems,
WithRichContent,
InHeader,
WithSelection,
} from './components';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { ChangelogComponent } from '@/components/ChangelogComponent';
import { CodeBlock } from '@/components/CodeBlock';
import {
SearchAutocompleteDefinition,
SearchAutocompleteItemDefinition,
} from '../../../utils/definitions';
export const reactAriaUrls = {
autocomplete: 'https://react-aria.adobe.com/Autocomplete',
};
<PageTitle
title="SearchAutocomplete"
description="A search input with an accessible results dropdown."
/>
<Snippet align="center" py={4} preview={<WithItems />} code={defaultSnippet} />
## Usage
<CodeBlock code={usage} />
## API reference
### SearchAutocomplete
<PropsTable data={searchAutocompletePropDefs} />
<ReactAriaLink component="Autocomplete" href={reactAriaUrls.autocomplete} />
### SearchAutocompleteItem
Individual result item within the autocomplete list.
<PropsTable data={searchAutocompleteItemPropDefs} />
## Examples
### With rich content
Here's a view when items include a title and description.
<Snippet
align="center"
py={4}
open
preview={<WithRichContent />}
code={withRichContent}
/>
### In header
Use `popoverWidth` to control the dropdown width when placed in constrained layouts like `PluginHeader`.
<Snippet py={4} open preview={<InHeader />} code={inHeader} />
### With selection
Use `onAction` on items to handle selection and reset the input.
<Snippet
align="center"
py={4}
open
preview={<WithSelection />}
code={withSelection}
/>
<Theming definition={SearchAutocompleteDefinition} />
<Theming definition={SearchAutocompleteItemDefinition} />
<ChangelogComponent component="search-autocomplete" />
@@ -0,0 +1,97 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
import { Chip } from '@/components/Chip';
export const searchAutocompletePropDefs: Record<string, PropDef> = {
'aria-label': {
type: 'string',
description: 'Accessible label for the search input.',
},
'aria-labelledby': {
type: 'string',
description: 'ID of the element that labels the search input.',
},
inputValue: {
type: 'string',
description: 'The current input value (controlled).',
},
onInputChange: {
type: 'enum',
values: ['(value: string) => void'],
description: 'Handler called when the input value changes.',
},
placeholder: {
type: 'string',
default: 'Search',
description:
'Placeholder text shown when the input is empty. Also used as the accessible label when neither aria-label nor aria-labelledby is provided.',
},
size: {
type: 'enum',
values: ['small', 'medium'],
default: 'small',
responsive: true,
description: (
<>
Visual size of the input. Use <Chip>small</Chip> for inline or dense
layouts, <Chip>medium</Chip> for standalone fields.
</>
),
},
isLoading: {
type: 'boolean',
default: 'false',
description:
'Whether results are currently loading. Dims existing results and announces loading state to screen readers.',
},
popoverWidth: {
type: 'string',
description:
'Width of the results popover. Accepts any CSS width value. Matches the input width when not set.',
},
popoverPlacement: {
type: 'enum',
values: ['bottom start', 'bottom end', 'top start', 'top end'],
default: 'bottom start',
description: 'Placement of the results popover relative to the input.',
},
defaultOpen: {
type: 'boolean',
default: 'false',
description: 'Whether the results popover is open by default.',
},
children: {
type: 'enum',
values: ['ReactNode'],
description: 'The result items to render inside the autocomplete.',
},
...classNamePropDefs,
...stylePropDefs,
};
export const searchAutocompleteItemPropDefs: Record<string, PropDef> = {
id: {
type: 'string',
description: 'Unique identifier for the item.',
},
textValue: {
type: 'string',
description:
'Plain text value used for keyboard navigation and accessibility.',
},
onAction: {
type: 'enum',
values: ['() => void'],
description: 'Handler called when the item is selected.',
},
children: {
type: 'enum',
values: ['ReactNode'],
required: true,
description: 'Content to render inside the item.',
},
...classNamePropDefs,
};
@@ -0,0 +1,139 @@
export const usage = `import { SearchAutocomplete, SearchAutocompleteItem } from '@backstage/ui';
<SearchAutocomplete
inputValue={inputValue}
onInputChange={setInputValue}
>
{items.map(item => (
<SearchAutocompleteItem key={item.id} id={item.id} textValue={item.name}>
{item.name}
</SearchAutocompleteItem>
))}
</SearchAutocomplete>`;
export const defaultSnippet = `const fruits = [
{ id: 'apple', name: 'Apple' },
{ id: 'banana', name: 'Banana' },
{ id: 'cherry', name: 'Cherry' },
{ id: 'grape', name: 'Grape' },
{ id: 'orange', name: 'Orange' },
];
function Example() {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<SearchAutocomplete
placeholder="Search fruits..."
inputValue={inputValue}
onInputChange={setInputValue}
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
>
{fruit.name}
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
);
}`;
export const withRichContent = `const fruits = [
{ id: 'apple', name: 'Apple', description: 'A round fruit' },
{ id: 'banana', name: 'Banana', description: 'A yellow curved fruit' },
{ id: 'cherry', name: 'Cherry', description: 'A small red stone fruit' },
];
function Example() {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<SearchAutocomplete
placeholder="Search fruits..."
inputValue={inputValue}
onInputChange={setInputValue}
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
>
<Text weight="bold">{fruit.name}</Text>
<Text variant="body-small" color="secondary">
{fruit.description}
</Text>
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
);
}`;
export const inHeader = `<PluginHeader
title="Title"
customActions={
<>
<SearchAutocomplete
size="small"
inputValue={inputValue}
onInputChange={setInputValue}
popoverWidth="400px"
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
>
{fruit.name}
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
</>
}
/>`;
export const withSelection = `function Example() {
const [inputValue, setInputValue] = useState('');
const [selected, setSelected] = useState<string | null>(null);
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<Flex direction="column" gap="4">
<SearchAutocomplete
placeholder="Search fruits..."
inputValue={inputValue}
onInputChange={setInputValue}
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
onAction={() => {
setSelected(fruit.name);
setInputValue('');
}}
>
{fruit.name}
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
<Text>Last selected: {selected ?? 'none'}</Text>
</Flex>
);
}`;
+4
View File
@@ -85,6 +85,10 @@ export const components: Page[] = [
title: 'RadioGroup',
slug: 'radio-group',
},
{
title: 'SearchAutocomplete',
slug: 'search-autocomplete',
},
{
title: 'SearchField',
slug: 'search-field',
+1
View File
@@ -23,6 +23,7 @@ export type Component =
| 'password-field'
| 'radio-group'
| 'scrollarea'
| 'search-autocomplete'
| 'searchfield'
| 'select'
| 'skeleton'