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,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>
|
||||
);
|
||||
}`;
|
||||
Reference in New Issue
Block a user