diff --git a/.changeset/fine-dragons-scream.md b/.changeset/fine-dragons-scream.md new file mode 100644 index 0000000000..b1373e25f3 --- /dev/null +++ b/.changeset/fine-dragons-scream.md @@ -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 diff --git a/docs-ui/src/app/components/search-autocomplete/components.tsx b/docs-ui/src/app/components/search-autocomplete/components.tsx new file mode 100644 index 0000000000..4052ee0e3f --- /dev/null +++ b/docs-ui/src/app/components/search-autocomplete/components.tsx @@ -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 ( + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + ); +}; + +export const WithRichContent = () => { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + {filtered.map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); +}; + +export const InHeader = () => { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + + } + /> + + ); +}; + +export const WithSelection = () => { + const [inputValue, setInputValue] = useState(''); + const [selected, setSelected] = useState(null); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + + {filtered.map(fruit => ( + { + setSelected(fruit.name); + setInputValue(''); + }} + > + {fruit.name} + + ))} + + Last selected: {selected ?? 'none'} + + ); +}; diff --git a/docs-ui/src/app/components/search-autocomplete/page.mdx b/docs-ui/src/app/components/search-autocomplete/page.mdx new file mode 100644 index 0000000000..ecb58f3377 --- /dev/null +++ b/docs-ui/src/app/components/search-autocomplete/page.mdx @@ -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', +}; + + + +} code={defaultSnippet} /> + +## Usage + + + +## API reference + +### SearchAutocomplete + + + + + +### SearchAutocompleteItem + +Individual result item within the autocomplete list. + + + +## Examples + +### With rich content + +Here's a view when items include a title and description. + +} + code={withRichContent} +/> + +### In header + +Use `popoverWidth` to control the dropdown width when placed in constrained layouts like `PluginHeader`. + +} code={inHeader} /> + +### With selection + +Use `onAction` on items to handle selection and reset the input. + +} + code={withSelection} +/> + + + + + + diff --git a/docs-ui/src/app/components/search-autocomplete/props-definition.tsx b/docs-ui/src/app/components/search-autocomplete/props-definition.tsx new file mode 100644 index 0000000000..7ddaea61e2 --- /dev/null +++ b/docs-ui/src/app/components/search-autocomplete/props-definition.tsx @@ -0,0 +1,97 @@ +import { + classNamePropDefs, + stylePropDefs, + type PropDef, +} from '@/utils/propDefs'; +import { Chip } from '@/components/Chip'; + +export const searchAutocompletePropDefs: Record = { + '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 small for inline or dense + layouts, medium 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 = { + 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, +}; diff --git a/docs-ui/src/app/components/search-autocomplete/snippets.ts b/docs-ui/src/app/components/search-autocomplete/snippets.ts new file mode 100644 index 0000000000..fe799ff828 --- /dev/null +++ b/docs-ui/src/app/components/search-autocomplete/snippets.ts @@ -0,0 +1,139 @@ +export const usage = `import { SearchAutocomplete, SearchAutocompleteItem } from '@backstage/ui'; + + + {items.map(item => ( + + {item.name} + + ))} +`; + +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 ( + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + ); +}`; + +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 ( + + {filtered.map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); +}`; + +export const inHeader = ` + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + + } +/>`; + +export const withSelection = `function Example() { + const [inputValue, setInputValue] = useState(''); + const [selected, setSelected] = useState(null); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + + {filtered.map(fruit => ( + { + setSelected(fruit.name); + setInputValue(''); + }} + > + {fruit.name} + + ))} + + Last selected: {selected ?? 'none'} + + ); +}`; diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index 753473ab64..fc1f841b55 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -85,6 +85,10 @@ export const components: Page[] = [ title: 'RadioGroup', slug: 'radio-group', }, + { + title: 'SearchAutocomplete', + slug: 'search-autocomplete', + }, { title: 'SearchField', slug: 'search-field', diff --git a/docs-ui/src/utils/types.ts b/docs-ui/src/utils/types.ts index c2c383796f..670bee3f05 100644 --- a/docs-ui/src/utils/types.ts +++ b/docs-ui/src/utils/types.ts @@ -23,6 +23,7 @@ export type Component = | 'password-field' | 'radio-group' | 'scrollarea' + | 'search-autocomplete' | 'searchfield' | 'select' | 'skeleton' diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 09454cecc9..b254b4f773 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -2074,6 +2074,85 @@ export type RowRenderFn = (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 {} + +// @public (undocumented) +export type SearchAutocompleteOwnProps = { + inputValue?: string; + onInputChange?: (value: string) => void; + size?: 'small' | 'medium' | Partial>; + '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 diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css new file mode 100644 index 0000000000..ca6de73402 --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.module.css @@ -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); + } +} diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx new file mode 100644 index 0000000000..1a044a9da8 --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.stories.tsx @@ -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 ( + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + ); + }, +}); + +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 ( + + {filtered.map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); + }, +}); + +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([]); + 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 ( + + {items.map(item => ( + + {item.name} + + {item.description} + + + ))} + + ); + }, +}); + +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(null); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + + {filtered.map(fruit => ( + { + setSelected(fruit.name); + setInputValue(''); + }} + > + {fruit.name} + + ))} + + Last selected: {selected ?? 'none'} + + ); + }, +}); + +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 ( + + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + + ); + }, +}); + +export const InHeader = meta.story({ + args: { + 'aria-label': 'Search', + placeholder: 'Search...', + }, + decorators: [ + Story => ( + + + + ), + ], + render: function Render(args) { + const [inputValue, setInputValue] = useState(''); + + const filtered = fruits.filter(fruit => + fruit.name.toLowerCase().includes(inputValue.toLowerCase()), + ); + + return ( + + } + size="small" + variant="secondary" + /> + + {filtered.map(fruit => ( + + {fruit.name} + + ))} + + } + 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 ( + + {filtered.map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); + }, +}); + +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 ( + + {filtered.map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); + }, +}); + +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 ( + {}}> + {fruits.slice(0, 3).map(fruit => ( + + {fruit.name} + + {fruit.description} + + + ))} + + ); + }, +}); diff --git a/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx new file mode 100644 index 0000000000..d8c4640e26 --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/SearchAutocomplete.tsx @@ -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
No results found.
; +}; + +/** @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(null); + const popoverRef = useRef(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 ( + + { + onInputChange?.(value); + if (value) { + overlayState.open(); + } else { + overlayState.close(); + } + }} + > + { + if (e.key === 'Enter' && !overlayState.isOpen && hasValue) { + e.preventDefault(); + overlayState.open(); + } + }} + > +
+ + + + + +
+
+ {/* isNonModal keeps the page interactive while the popover is open, + required for virtual focus (aria-activedescendant) to work correctly */} + + + + + isLoading ? ( +
Searching...
+ ) : ( + + ) + } + onAction={() => { + overlayState.close(); + }} + > + {children} +
+
+
+
+ + {liveMessage} + +
+
+ ); +} + +/** @public */ +export function SearchAutocompleteItem(props: SearchAutocompleteItemProps) { + const { ownProps, restProps } = useDefinition( + SearchAutocompleteItemDefinition, + props, + ); + const { classes, children } = ownProps; + + return ( + +
{children}
+
+ ); +} diff --git a/packages/ui/src/components/SearchAutocomplete/definition.ts b/packages/ui/src/components/SearchAutocomplete/definition.ts new file mode 100644 index 0000000000..4a2e47881d --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/definition.ts @@ -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()({ + 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()({ + styles, + classNames: { + root: 'bui-SearchAutocompleteItem', + itemContent: 'bui-SearchAutocompleteItemContent', + }, + propDefs: { + children: {}, + className: {}, + }, + }); diff --git a/packages/ui/src/components/SearchAutocomplete/index.ts b/packages/ui/src/components/SearchAutocomplete/index.ts new file mode 100644 index 0000000000..8e15383008 --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/index.ts @@ -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'; diff --git a/packages/ui/src/components/SearchAutocomplete/types.ts b/packages/ui/src/components/SearchAutocomplete/types.ts new file mode 100644 index 0000000000..a5920d370c --- /dev/null +++ b/packages/ui/src/components/SearchAutocomplete/types.ts @@ -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>; + + /** + * 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 {} diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index a07fff7462..79c5a8b884 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -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'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 290ae2a80f..2bf478f5fb 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -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';