Add Combobox component to Backstage UI (#34118)

Introduces a Combobox component to Backstage UI for times when you want to allow users to choose from a list of values but also specify their own in some cases.

---------

Signed-off-by: James Brooks <jamesbrooks@spotify.com>
This commit is contained in:
James Brooks
2026-05-05 15:29:15 +01:00
committed by GitHub
parent 37535b2a60
commit ddca41f775
18 changed files with 1652 additions and 0 deletions
@@ -0,0 +1,151 @@
'use client';
import { Combobox } from '../../../../../packages/ui/src/components/Combobox/Combobox';
import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex';
import { RiCloudLine } from '@remixicon/react';
const fontOptions = [
{ value: 'sans', label: 'Sans-serif' },
{ value: 'serif', label: 'Serif' },
{ value: 'mono', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
];
const countries = [
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
{ value: 'mx', label: 'Mexico' },
{ value: 'uk', label: 'United Kingdom' },
{ value: 'fr', label: 'France' },
{ value: 'de', label: 'Germany' },
{ value: 'it', label: 'Italy' },
{ value: 'es', label: 'Spain' },
{ value: 'jp', label: 'Japan' },
{ value: 'cn', label: 'China' },
{ value: 'in', label: 'India' },
{ value: 'br', label: 'Brazil' },
{ value: 'au', label: 'Australia' },
];
const sectionedFonts = [
{
title: 'Serif Fonts',
options: [
{ value: 'times', label: 'Times New Roman' },
{ value: 'georgia', label: 'Georgia' },
{ value: 'garamond', label: 'Garamond' },
],
},
{
title: 'Sans-Serif Fonts',
options: [
{ value: 'arial', label: 'Arial' },
{ value: 'helvetica', label: 'Helvetica' },
{ value: 'verdana', label: 'Verdana' },
],
},
{
title: 'Monospace Fonts',
options: [
{ value: 'courier', label: 'Courier New' },
{ value: 'consolas', label: 'Consolas' },
{ value: 'fira', label: 'Fira Code' },
],
},
];
export const Preview = () => (
<Combobox
label="Font Family"
options={fontOptions}
placeholder="Pick a font"
name="font"
style={{ maxWidth: 260 }}
/>
);
export const WithLabelAndDescription = () => (
<Combobox
label="Font Family"
description="Choose a font family for your document"
options={fontOptions}
placeholder="Pick a font"
name="font"
style={{ width: 300 }}
/>
);
export const Sizes = () => (
<Flex direction="row" gap="2">
<Combobox
label="Small"
size="small"
options={fontOptions}
name="font-small"
placeholder="Pick a font"
style={{ maxWidth: 260 }}
/>
<Combobox
label="Medium"
size="medium"
options={fontOptions}
name="font-medium"
placeholder="Pick a font"
style={{ maxWidth: 260 }}
/>
</Flex>
);
export const WithIcon = () => (
<Combobox
label="Font Family"
options={fontOptions}
placeholder="Pick a font"
name="font"
icon={<RiCloudLine />}
style={{ width: 300 }}
/>
);
export const Disabled = () => (
<Combobox
label="Font Family"
options={fontOptions}
placeholder="Pick a font"
name="font"
isDisabled
style={{ width: 300 }}
/>
);
export const AllowsCustomValue = () => (
<Combobox
label="Country"
options={countries}
placeholder="Type any country"
allowsCustomValue
name="country"
style={{ width: 300 }}
/>
);
export const DisabledOption = () => (
<Combobox
label="Font Family"
options={fontOptions}
placeholder="Pick a font"
name="font"
disabledKeys={['serif']}
style={{ width: 300 }}
/>
);
export const WithSections = () => (
<Combobox
label="Font Family"
options={sectionedFonts}
placeholder="Pick a font"
name="font"
style={{ width: 300 }}
/>
);
@@ -0,0 +1,153 @@
import { PropsTable } from '@/components/PropsTable';
import { Snippet } from '@/components/Snippet';
import { CodeBlock } from '@/components/CodeBlock';
import { ReactAriaLink } from '@/components/ReactAriaLink';
import {
Preview,
WithLabelAndDescription,
Sizes,
WithIcon,
Disabled,
DisabledOption,
AllowsCustomValue,
WithSections,
} from './components';
import { comboboxPropDefs } from './props-definition';
import {
optionPropDefs,
optionSectionPropDefs,
} from '../select/props-definition';
import {
comboboxUsageSnippet,
comboboxDefaultSnippet,
comboboxDescriptionSnippet,
comboboxSizesSnippet,
comboboxDisabledSnippet,
comboboxResponsiveSnippet,
comboboxIconSnippet,
comboboxDisabledOptionsSnippet,
comboboxCustomValueSnippet,
comboboxSectionsSnippet,
} from './snippets';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
import { ChangelogComponent } from '@/components/ChangelogComponent';
import { ComboboxDefinition } from '../../../utils/definitions';
export const reactAriaUrls = {
combobox: 'https://react-aria.adobe.com/ComboBox',
};
<PageTitle
title="Combobox"
description="A text input paired with a filterable dropdown for selecting or typing a value."
/>
<Snippet
align="center"
py={4}
preview={<Preview />}
code={comboboxDefaultSnippet}
/>
## Usage
<CodeBlock code={comboboxUsageSnippet} />
## API reference
<PropsTable data={comboboxPropDefs} />
<ReactAriaLink component="ComboBox" href={reactAriaUrls.combobox} />
### Option types
The `options` prop accepts an array containing either of the following shapes.
#### `Option`
<PropsTable data={optionPropDefs} />
#### `OptionSection`
<PropsTable data={optionSectionPropDefs} />
## Examples
### Label and description
<Snippet
layout="side-by-side"
open
preview={<WithLabelAndDescription />}
code={comboboxDescriptionSnippet}
/>
### Sizes
<Snippet
layout="side-by-side"
open
preview={<Sizes />}
code={comboboxSizesSnippet}
/>
### With icon
<Snippet
layout="side-by-side"
open
preview={<WithIcon />}
code={comboboxIconSnippet}
/>
### Disabled
<Snippet
layout="side-by-side"
open
preview={<Disabled />}
code={comboboxDisabledSnippet}
/>
### Disabled options
<Snippet
layout="side-by-side"
open
preview={<DisabledOption />}
code={comboboxDisabledOptionsSnippet}
/>
### Custom values
Allow the user to type a value that is not in the option list by setting `allowsCustomValue`.
<Snippet
layout="side-by-side"
open
preview={<AllowsCustomValue />}
code={comboboxCustomValueSnippet}
/>
### With sections
Group options under section headings by passing objects with a `title` and a
nested `options` array.
<Snippet
layout="side-by-side"
open
preview={<WithSections />}
code={comboboxSectionsSnippet}
/>
### Responsive
Size can change at different breakpoints.
<CodeBlock code={comboboxResponsiveSnippet} />
<Theming definition={ComboboxDefinition} />
<ChangelogComponent component="combobox" />
@@ -0,0 +1,113 @@
import {
classNamePropDefs,
stylePropDefs,
type PropDef,
} from '@/utils/propDefs';
import { Chip } from '@/components/Chip';
export const comboboxPropDefs: Record<string, PropDef> = {
options: {
type: 'enum',
values: ['(Option | OptionSection)[]'],
description: (
<>
Options to display in the dropdown. Pass <Chip>Option</Chip> objects
directly, or <Chip>OptionSection</Chip> objects to render grouped
options under section headings.
</>
),
},
allowsCustomValue: {
type: 'boolean',
default: 'false',
description:
'When true, the typed text is accepted as the value on blur or Enter even if it does not match any option.',
},
value: {
type: 'string',
description: 'Controlled selected value.',
},
defaultValue: {
type: 'string',
description: 'Initial value for uncontrolled usage.',
},
onChange: {
type: 'enum',
values: ['(value: Key | null) => void'],
description: 'Called when the selected option changes.',
},
inputValue: {
type: 'string',
description: 'Controlled input text.',
},
defaultInputValue: {
type: 'string',
description: 'Initial input text for uncontrolled usage.',
},
onInputChange: {
type: 'enum',
values: ['(value: string) => void'],
description: 'Called when the input text changes.',
},
label: {
type: 'string',
description: 'Visible label above the combobox.',
},
secondaryLabel: {
type: 'string',
description: (
<>
Secondary text shown next to the label. If not provided and isRequired
is true, displays <Chip>Required</Chip>.
</>
),
},
description: {
type: 'string',
description: 'Helper text displayed below the label.',
},
placeholder: {
type: 'string',
description: 'Text shown when the input is empty.',
},
size: {
type: 'enum',
values: ['small', 'medium'],
default: 'small',
responsive: true,
description: 'Visual size of the combobox field.',
},
icon: {
type: 'enum',
values: ['ReactNode'],
description: 'Icon displayed before the input.',
},
onOpenChange: {
type: 'enum',
values: ['(isOpen: boolean) => void'],
description: 'Called when the dropdown opens or closes.',
},
isDisabled: {
type: 'boolean',
description: 'Prevents user interaction when true.',
},
disabledKeys: {
type: 'enum',
values: ['Iterable<Key>'],
description: 'Keys of options that should be disabled.',
},
isRequired: {
type: 'boolean',
description: 'Marks the field as required for form validation.',
},
isInvalid: {
type: 'boolean',
description: 'Displays the combobox in an error state.',
},
name: {
type: 'string',
description: 'Form field name for form submission.',
},
...classNamePropDefs,
...stylePropDefs,
};
@@ -0,0 +1,114 @@
export const comboboxUsageSnippet = `import { Combobox } from '@backstage/ui';
<Combobox
name="font"
label="Font Family"
options={[
{ value: 'sans', label: 'Sans-serif' },
{ value: 'serif', label: 'Serif' },
{ value: 'mono', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]}
/>`;
export const comboboxDefaultSnippet = `<Combobox
name="font"
label="Font Family"
placeholder="Pick a font"
options={[
{ value: 'sans', label: 'Sans-serif' },
{ value: 'serif', label: 'Serif' },
{ value: 'mono', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]}
/>`;
export const comboboxDescriptionSnippet = `<Combobox
name="font"
label="Font Family"
description="Choose a font family for your document"
options={[ ... ]}
/>`;
export const comboboxIconSnippet = `<Combobox
name="font"
label="Font Family"
icon={<RiCloudLine />}
options={[ ... ]}
/>`;
export const comboboxSizesSnippet = `<Flex>
<Combobox
size="small"
label="Font family"
options={[ ... ]}
/>
<Combobox
size="medium"
label="Font family"
options={[ ... ]}
/>
</Flex>`;
export const comboboxDisabledSnippet = `<Combobox
isDisabled
label="Font family"
options={[ ... ]}
/>`;
export const comboboxResponsiveSnippet = `<Combobox
size={{ initial: 'small', lg: 'medium' }}
label="Font family"
options={[ ... ]}
/>`;
export const comboboxDisabledOptionsSnippet = `<Combobox
name="font"
label="Font Family"
placeholder="Pick a font"
disabledKeys={['cursive', 'serif']}
options={[
{ value: 'sans', label: 'Sans-serif' },
{ value: 'serif', label: 'Serif' },
{ value: 'mono', label: 'Monospace' },
{ value: 'cursive', label: 'Cursive' },
]}
/>`;
export const comboboxCustomValueSnippet = `<Combobox
name="country"
label="Country"
allowsCustomValue
placeholder="Type any country"
options={[
{ value: 'us', label: 'United States' },
{ value: 'ca', label: 'Canada' },
{ value: 'uk', label: 'United Kingdom' },
{ value: 'fr', label: 'France' },
{ value: 'de', label: 'Germany' },
// ... more options
]}
/>`;
export const comboboxSectionsSnippet = `<Combobox
name="font"
label="Font Family"
options={[
{
title: 'Serif Fonts',
options: [
{ value: 'times', label: 'Times New Roman' },
{ value: 'georgia', label: 'Georgia' },
{ value: 'garamond', label: 'Garamond' },
],
},
{
title: 'Sans-Serif Fonts',
options: [
{ value: 'arial', label: 'Arial' },
{ value: 'helvetica', label: 'Helvetica' },
{ value: 'verdana', label: 'Verdana' },
],
},
]}
/>`;
+5
View File
@@ -49,6 +49,11 @@ export const components: Page[] = [
title: 'CheckboxGroup',
slug: 'checkbox-group',
},
{
title: 'Combobox',
slug: 'combobox',
status: 'new',
},
{
title: 'Container',
slug: 'container',