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:
@@ -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>
|
||||
);
|
||||
}`;
|
||||
@@ -85,6 +85,10 @@ export const components: Page[] = [
|
||||
title: 'RadioGroup',
|
||||
slug: 'radio-group',
|
||||
},
|
||||
{
|
||||
title: 'SearchAutocomplete',
|
||||
slug: 'search-autocomplete',
|
||||
},
|
||||
{
|
||||
title: 'SearchField',
|
||||
slug: 'search-field',
|
||||
|
||||
@@ -23,6 +23,7 @@ export type Component =
|
||||
| 'password-field'
|
||||
| 'radio-group'
|
||||
| 'scrollarea'
|
||||
| 'search-autocomplete'
|
||||
| 'searchfield'
|
||||
| 'select'
|
||||
| 'skeleton'
|
||||
|
||||
@@ -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> {}
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user