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
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added `SearchAutocomplete` and `SearchAutocompleteItem` components for building accessible search-with-results patterns. Built on React Aria's Autocomplete with keyboard navigation and screen reader support. Designed for async/external search results with a configurable popover width.
**Affected components:** SearchAutocomplete, SearchAutocompleteItem
@@ -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'
+79
View File
@@ -2074,6 +2074,85 @@ export type RowRenderFn<T extends TableItem> = (params: {
index: number;
}) => ReactNode;
// @public (undocumented)
export function SearchAutocomplete(
props: SearchAutocompleteProps,
): JSX_2.Element;
// @public
export const SearchAutocompleteDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-SearchAutocomplete';
readonly searchField: 'bui-SearchAutocompleteSearchField';
readonly searchFieldInput: 'bui-SearchAutocompleteInput';
readonly searchFieldClear: 'bui-SearchAutocompleteClear';
readonly popover: 'bui-SearchAutocompletePopover';
readonly inner: 'bui-SearchAutocompleteInner';
readonly listBox: 'bui-SearchAutocompleteListBox';
readonly loadingState: 'bui-SearchAutocompleteLoadingState';
readonly emptyState: 'bui-SearchAutocompleteEmptyState';
};
readonly propDefs: {
readonly 'aria-label': {};
readonly 'aria-labelledby': {};
readonly size: {
readonly dataAttribute: true;
readonly default: 'small';
};
readonly placeholder: {
readonly default: 'Search';
};
readonly inputValue: {};
readonly onInputChange: {};
readonly popoverWidth: {};
readonly popoverPlacement: {};
readonly children: {};
readonly isLoading: {};
readonly defaultOpen: {};
readonly className: {};
readonly style: {};
};
};
// @public (undocumented)
export function SearchAutocompleteItem(
props: SearchAutocompleteItemProps,
): JSX_2.Element;
// @public (undocumented)
export type SearchAutocompleteItemOwnProps = {
children: ReactNode;
className?: string;
};
// @public (undocumented)
export interface SearchAutocompleteItemProps
extends SearchAutocompleteItemOwnProps,
Omit<ListBoxItemProps, keyof SearchAutocompleteItemOwnProps> {}
// @public (undocumented)
export type SearchAutocompleteOwnProps = {
inputValue?: string;
onInputChange?: (value: string) => void;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
'aria-label'?: string;
'aria-labelledby'?: string;
placeholder?: string;
popoverWidth?: string;
popoverPlacement?: PopoverProps_2['placement'];
children?: ReactNode;
isLoading?: boolean;
defaultOpen?: boolean;
className?: string;
style?: React.CSSProperties;
};
// @public (undocumented)
export interface SearchAutocompleteProps extends SearchAutocompleteOwnProps {}
// @public (undocumented)
export const SearchField: ForwardRefExoticComponent<
SearchFieldProps & RefAttributes<HTMLDivElement>
@@ -0,0 +1,214 @@
/*
* Copyright 2026 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.
*/
@layer tokens, base, components, utilities;
@layer components {
.bui-SearchAutocomplete {
display: flex;
align-items: center;
border-radius: var(--bui-radius-2);
background-color: var(--bui-bg-neutral-1);
transition: box-shadow 0.2s ease-in-out;
font-family: var(--bui-font-regular);
&:has([data-focused]) {
box-shadow: inset 0 0 0 1px var(--bui-ring);
}
&[data-size='small'] {
--search-autocomplete-item-height: 2rem;
height: 2rem;
}
&[data-size='medium'] {
--search-autocomplete-item-height: 2.5rem;
height: 2.5rem;
}
}
.bui-SearchAutocompleteSearchField {
display: flex;
align-items: center;
flex: 1;
& > div {
display: flex;
align-items: center;
flex: 1;
}
& svg {
flex: 0 0 auto;
color: var(--bui-fg-primary);
margin-inline-start: var(--bui-space-2);
.bui-SearchAutocomplete[data-size='small'] & {
width: 1rem;
height: 1rem;
}
.bui-SearchAutocomplete[data-size='medium'] & {
width: 1.25rem;
height: 1.25rem;
}
}
}
.bui-SearchAutocompleteInput {
flex: 1;
display: flex;
align-items: center;
padding: 0;
padding-inline: var(--bui-space-2);
border: none;
background-color: transparent;
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
color: var(--bui-fg-primary);
width: 100%;
height: 100%;
outline: none;
&::-webkit-search-cancel-button,
&::-webkit-search-decoration {
-webkit-appearance: none;
}
&::placeholder {
color: var(--bui-fg-secondary);
}
}
.bui-SearchAutocompleteClear {
flex: 0 0 auto;
display: grid;
place-content: center;
background-color: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: var(--bui-fg-secondary);
transition: color 0.2s ease-in-out;
width: var(--search-autocomplete-item-height);
height: var(--search-autocomplete-item-height);
&:hover {
color: var(--bui-fg-primary);
}
& svg {
width: 1rem;
height: 1rem;
}
}
.bui-SearchAutocompletePopover {
--menu-border-radius: var(--bui-radius-2);
display: flex;
flex-direction: column;
box-shadow: var(--bui-shadow);
border: 1px solid var(--bui-border-1);
border-radius: var(--menu-border-radius);
background: var(--bui-bg-app);
color: var(--bui-fg-primary);
outline: none;
transition: transform 200ms, opacity 200ms;
min-height: 0;
overflow: hidden;
&[data-entering],
&[data-exiting] {
transform: var(--origin);
opacity: 0;
}
&[data-placement='top'] {
--origin: translateY(8px);
}
&[data-placement='bottom'] {
--origin: translateY(-8px);
}
}
.bui-SearchAutocompleteInner {
border-radius: var(--menu-border-radius);
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
.bui-SearchAutocompleteListBox {
max-height: inherit;
box-sizing: border-box;
overflow: auto;
min-width: 150px;
outline: none;
padding-block: var(--bui-space-1);
&[data-stale] {
opacity: 0.6;
}
}
.bui-SearchAutocompleteItem {
padding-inline: var(--bui-space-1);
display: block;
&:focus-visible {
outline: none;
}
&[data-focus-visible] {
outline: 2px solid var(--bui-ring);
outline-offset: -2px;
}
&[data-focused] .bui-SearchAutocompleteItemContent,
&[data-hovered] .bui-SearchAutocompleteItemContent {
background: var(--bui-bg-neutral-2);
color: var(--bui-fg-primary);
}
}
.bui-SearchAutocompleteItemContent {
display: flex;
flex-direction: column;
gap: var(--bui-space-1);
min-height: 2rem;
padding-inline: var(--bui-space-2);
padding-block: var(--bui-space-2);
border-radius: var(--bui-radius-2);
outline: none;
cursor: default;
color: var(--bui-fg-primary);
font-size: var(--bui-font-size-3);
}
.bui-SearchAutocompleteLoadingState,
.bui-SearchAutocompleteEmptyState {
padding-inline: var(--bui-space-3);
padding-block: var(--bui-space-2);
color: var(--bui-fg-secondary);
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
}
}
@@ -0,0 +1,445 @@
/*
* Copyright 2026 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.
*/
/* eslint-disable no-restricted-syntax */
import preview from '../../../../../.storybook/preview';
import { useState, useEffect } from 'react';
import {
SearchAutocomplete,
SearchAutocompleteItem,
} from './SearchAutocomplete';
import { PluginHeader } from '../PluginHeader';
import { Flex } from '../Flex';
import { Text } from '../Text';
import { ButtonIcon } from '../ButtonIcon';
import { RiCactusLine } from '@remixicon/react';
import { MemoryRouter } from 'react-router-dom';
const meta = preview.meta({
title: 'Backstage UI/SearchAutocomplete',
component: SearchAutocomplete,
argTypes: {
size: {
control: 'select',
options: ['small', 'medium'],
},
placeholder: {
control: 'text',
},
popoverWidth: {
control: 'text',
},
},
});
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 = meta.story({
args: {
'aria-label': 'Search fruits',
placeholder: 'Search fruits...',
style: { maxWidth: '300px' },
},
render: function Render(args) {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<SearchAutocomplete
{...args}
inputValue={inputValue}
onInputChange={setInputValue}
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
>
{fruit.name}
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
);
},
});
export const WithRichContent = meta.story({
args: {
'aria-label': 'Search fruits',
placeholder: 'Search fruits...',
style: { maxWidth: '300px' },
},
render: function Render(args) {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<SearchAutocomplete
{...args}
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 WithAsyncItems = meta.story({
args: {
'aria-label': 'Search fruits',
placeholder: 'Search fruits...',
style: { maxWidth: '300px' },
},
render: function Render(args) {
const [inputValue, setInputValue] = useState('');
const [items, setItems] = useState<typeof fruits>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
if (!inputValue) {
setItems([]);
return undefined;
}
setIsLoading(true);
const timeout = setTimeout(() => {
setItems(
fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
),
);
setIsLoading(false);
}, 2000);
return () => clearTimeout(timeout);
}, [inputValue]);
return (
<SearchAutocomplete
{...args}
inputValue={inputValue}
onInputChange={setInputValue}
isLoading={isLoading}
>
{items.map(item => (
<SearchAutocompleteItem
key={item.id}
id={item.id}
textValue={item.name}
>
<Text weight="bold">{item.name}</Text>
<Text variant="body-small" color="secondary">
{item.description}
</Text>
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
);
},
});
export const WithSelection = meta.story({
args: {
'aria-label': 'Search fruits',
placeholder: 'Search fruits...',
style: { maxWidth: '300px' },
},
render: function Render(args) {
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
{...args}
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>
);
},
});
export const Sizes = meta.story({
args: {
'aria-label': 'Search',
placeholder: 'Search...',
},
render: function Render(args) {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<Flex
direction="row"
gap="4"
style={{ width: '100%', maxWidth: '600px' }}
>
<SearchAutocomplete
{...args}
size="small"
inputValue={inputValue}
onInputChange={setInputValue}
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
>
{fruit.name}
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
<SearchAutocomplete
{...args}
size="medium"
inputValue={inputValue}
onInputChange={setInputValue}
>
{filtered.map(fruit => (
<SearchAutocompleteItem
key={fruit.id}
id={fruit.id}
textValue={fruit.name}
>
{fruit.name}
</SearchAutocompleteItem>
))}
</SearchAutocomplete>
</Flex>
);
},
});
export const InHeader = meta.story({
args: {
'aria-label': 'Search',
placeholder: 'Search...',
},
decorators: [
Story => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
render: function Render(args) {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<PluginHeader
title="Title"
customActions={
<>
<ButtonIcon
aria-label="Cactus icon button"
icon={<RiCactusLine />}
size="small"
variant="secondary"
/>
<SearchAutocomplete
{...args}
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>
<ButtonIcon
aria-label="Cactus icon button"
icon={<RiCactusLine />}
size="small"
variant="secondary"
/>
</>
}
/>
);
},
});
export const WithPopoverWidth = meta.story({
args: {
'aria-label': 'Search fruits',
placeholder: 'Search fruits...',
popoverWidth: '500px',
style: { maxWidth: '300px' },
},
render: function Render(args) {
const [inputValue, setInputValue] = useState('');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<SearchAutocomplete
{...args}
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 OpenByDefault = meta.story({
args: {
'aria-label': 'Search fruits',
placeholder: 'Search fruits...',
defaultOpen: true,
style: { maxWidth: '300px' },
},
render: function Render(args) {
const [inputValue, setInputValue] = useState('ap');
const filtered = fruits.filter(fruit =>
fruit.name.toLowerCase().includes(inputValue.toLowerCase()),
);
return (
<SearchAutocomplete
{...args}
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 OpenWithLoading = meta.story({
args: {
'aria-label': 'Search fruits',
placeholder: 'Search fruits...',
defaultOpen: true,
isLoading: true,
inputValue: 'ap',
style: { maxWidth: '300px' },
},
render: function Render(args) {
return (
<SearchAutocomplete {...args} onInputChange={() => {}}>
{fruits.slice(0, 3).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>
);
},
});
@@ -0,0 +1,204 @@
/*
* Copyright 2026 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 { Children, useRef } from 'react';
import { useInteractOutside } from '@react-aria/interactions';
import {
Autocomplete,
SearchField as RASearchField,
Input,
Button as RAButton,
Popover as RAPopover,
ListBox,
ListBoxItem,
OverlayTriggerStateContext,
} from 'react-aria-components';
import { useOverlayTriggerState } from '@react-stately/overlays';
import { RiSearch2Line, RiCloseCircleLine } from '@remixicon/react';
import { useDefinition } from '../../hooks/useDefinition';
import {
SearchAutocompleteDefinition,
SearchAutocompleteItemDefinition,
} from './definition';
import { Box } from '../Box';
import { BgReset } from '../../hooks/useBg';
import { VisuallyHidden } from '../VisuallyHidden';
import type {
SearchAutocompleteProps,
SearchAutocompleteItemProps,
} from './types';
const SearchAutocompleteEmptyState = () => {
const { ownProps } = useDefinition(SearchAutocompleteDefinition, {});
return <div className={ownProps.classes.emptyState}>No results found.</div>;
};
/** @public */
export function SearchAutocomplete(props: SearchAutocompleteProps) {
const { ownProps, dataAttributes } = useDefinition(
SearchAutocompleteDefinition,
props,
);
const {
classes,
'aria-label': ariaLabel,
'aria-labelledby': ariaLabelledBy,
inputValue,
onInputChange,
placeholder,
popoverWidth,
popoverPlacement = 'bottom start',
children,
isLoading,
defaultOpen,
style,
} = ownProps;
const triggerRef = useRef<HTMLDivElement | null>(null);
const popoverRef = useRef<HTMLElement | null>(null);
const hasValue = !!inputValue;
const hasChildren = Children.count(children) > 0;
let liveMessage = '';
if (isLoading) {
liveMessage = 'Searching';
} else if (hasValue && !hasChildren) {
liveMessage = 'No results found';
}
const overlayState = useOverlayTriggerState({ defaultOpen });
// Close on interact outside — same pattern as ComboBox.
// isNonModal disables useOverlay's built-in useInteractOutside,
// so we add our own that filters out clicks on the trigger.
useInteractOutside({
ref: popoverRef,
onInteractOutside: e => {
const target = e.target as Element;
if (triggerRef.current?.contains(target)) {
return;
}
overlayState.close();
},
isDisabled: !overlayState.isOpen,
});
return (
<OverlayTriggerStateContext.Provider value={overlayState}>
<Autocomplete
inputValue={inputValue}
onInputChange={value => {
onInputChange?.(value);
if (value) {
overlayState.open();
} else {
overlayState.close();
}
}}
>
<RASearchField
className={classes.searchField}
aria-label={ariaLabel ?? (ariaLabelledBy ? undefined : placeholder)}
aria-labelledby={ariaLabelledBy}
data-size={dataAttributes['data-size']}
onKeyDown={e => {
if (e.key === 'Enter' && !overlayState.isOpen && hasValue) {
e.preventDefault();
overlayState.open();
}
}}
>
<div
ref={triggerRef}
className={classes.root}
data-size={dataAttributes['data-size']}
style={style}
>
<div aria-hidden="true">
<RiSearch2Line />
</div>
<Input
className={classes.searchFieldInput}
placeholder={placeholder}
/>
<RAButton
className={classes.searchFieldClear}
style={{ visibility: hasValue ? 'visible' : 'hidden' }}
>
<RiCloseCircleLine />
</RAButton>
</div>
</RASearchField>
{/* isNonModal keeps the page interactive while the popover is open,
required for virtual focus (aria-activedescendant) to work correctly */}
<RAPopover
ref={popoverRef}
className={classes.popover}
triggerRef={triggerRef}
isNonModal
placement={popoverPlacement}
{...(popoverWidth ? { style: { width: popoverWidth } } : {})}
>
<BgReset>
<Box bg="neutral" className={classes.inner}>
<ListBox
className={classes.listBox}
autoFocus="first"
shouldFocusOnHover
aria-busy={isLoading || undefined}
data-stale={isLoading || undefined}
renderEmptyState={() =>
isLoading ? (
<div className={classes.loadingState}>Searching...</div>
) : (
<SearchAutocompleteEmptyState />
)
}
onAction={() => {
overlayState.close();
}}
>
{children}
</ListBox>
</Box>
</BgReset>
</RAPopover>
<VisuallyHidden aria-live="polite" aria-atomic="true">
{liveMessage}
</VisuallyHidden>
</Autocomplete>
</OverlayTriggerStateContext.Provider>
);
}
/** @public */
export function SearchAutocompleteItem(props: SearchAutocompleteItemProps) {
const { ownProps, restProps } = useDefinition(
SearchAutocompleteItemDefinition,
props,
);
const { classes, children } = ownProps;
return (
<ListBoxItem
textValue={typeof children === 'string' ? children : undefined}
className={classes.root}
{...restProps}
>
<div className={classes.itemContent}>{children}</div>
</ListBoxItem>
);
}
@@ -0,0 +1,71 @@
/*
* Copyright 2026 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 { defineComponent } from '../../hooks/useDefinition';
import type {
SearchAutocompleteOwnProps,
SearchAutocompleteItemOwnProps,
} from './types';
import styles from './SearchAutocomplete.module.css';
/**
* Component definition for SearchAutocomplete.
* @public
*/
export const SearchAutocompleteDefinition =
defineComponent<SearchAutocompleteOwnProps>()({
styles,
classNames: {
root: 'bui-SearchAutocomplete',
searchField: 'bui-SearchAutocompleteSearchField',
searchFieldInput: 'bui-SearchAutocompleteInput',
searchFieldClear: 'bui-SearchAutocompleteClear',
popover: 'bui-SearchAutocompletePopover',
inner: 'bui-SearchAutocompleteInner',
listBox: 'bui-SearchAutocompleteListBox',
loadingState: 'bui-SearchAutocompleteLoadingState',
emptyState: 'bui-SearchAutocompleteEmptyState',
},
propDefs: {
'aria-label': {},
'aria-labelledby': {},
size: { dataAttribute: true, default: 'small' },
placeholder: { default: 'Search' },
inputValue: {},
onInputChange: {},
popoverWidth: {},
popoverPlacement: {},
children: {},
isLoading: {},
defaultOpen: {},
className: {},
style: {},
},
});
/** @internal */
export const SearchAutocompleteItemDefinition =
defineComponent<SearchAutocompleteItemOwnProps>()({
styles,
classNames: {
root: 'bui-SearchAutocompleteItem',
itemContent: 'bui-SearchAutocompleteItemContent',
},
propDefs: {
children: {},
className: {},
},
});
@@ -0,0 +1,27 @@
/*
* Copyright 2026 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,
SearchAutocompleteItem,
} from './SearchAutocomplete';
export { SearchAutocompleteDefinition } from './definition';
export type {
SearchAutocompleteProps,
SearchAutocompleteItemProps,
SearchAutocompleteOwnProps,
SearchAutocompleteItemOwnProps,
} from './types';
@@ -0,0 +1,101 @@
/*
* Copyright 2026 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 type {
ListBoxItemProps as AriaListBoxItemProps,
PopoverProps as AriaPopoverProps,
} from 'react-aria-components';
import type { ReactNode } from 'react';
import type { Breakpoint } from '../../types';
/** @public */
export type SearchAutocompleteOwnProps = {
/**
* The current value of the search input (controlled).
*/
inputValue?: string;
/**
* Handler called when the search input value changes.
*/
onInputChange?: (value: string) => void;
/**
* The size of the search input.
* @defaultValue 'small'
*/
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
/**
* Accessible label for the search input.
*/
'aria-label'?: string;
/**
* ID of the element that labels the search input.
*/
'aria-labelledby'?: string;
/**
* The placeholder text for the search input.
*/
placeholder?: string;
/**
* Width of the results popover. Accepts any CSS width value.
* When not set, the popover matches the input width.
*/
popoverWidth?: string;
/**
* Placement of the results popover relative to the input.
* @defaultValue 'bottom start'
*/
popoverPlacement?: AriaPopoverProps['placement'];
/**
* The result items to render inside the autocomplete.
*/
children?: ReactNode;
/**
* Whether results are currently loading.
* When true, displays a loading indicator and announces the loading state to screen readers.
*/
isLoading?: boolean;
/**
* Whether the results popover is open by default.
*/
defaultOpen?: boolean;
className?: string;
style?: React.CSSProperties;
};
/** @public */
export interface SearchAutocompleteProps extends SearchAutocompleteOwnProps {}
/** @public */
export type SearchAutocompleteItemOwnProps = {
children: ReactNode;
className?: string;
};
/** @public */
export interface SearchAutocompleteItemProps
extends SearchAutocompleteItemOwnProps,
Omit<AriaListBoxItemProps, keyof SearchAutocompleteItemOwnProps> {}
+4
View File
@@ -52,6 +52,10 @@ export { MenuDefinition } from './components/Menu/definition';
export { PasswordFieldDefinition } from './components/PasswordField/definition';
export { PopoverDefinition } from './components/Popover/definition';
export { RadioGroupDefinition } from './components/RadioGroup/definition';
export {
SearchAutocompleteDefinition,
SearchAutocompleteItemDefinition,
} from './components/SearchAutocomplete/definition';
export { SearchFieldDefinition } from './components/SearchField/definition';
export { SelectDefinition } from './components/Select/definition';
export { SkeletonDefinition } from './components/Skeleton/definition';
+1
View File
@@ -51,6 +51,7 @@ export * from './components/PasswordField';
export * from './components/Tooltip';
export * from './components/Menu';
export * from './components/Popover';
export * from './components/SearchAutocomplete';
export * from './components/SearchField';
export * from './components/Link';
export * from './components/Select';