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
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added a new `Combobox` component. It pairs a text input with a filterable dropdown of options and supports single selection, sectioned options, icons, sizes, and custom typed values via `allowsCustomValue`.
**Affected components:** Combobox
@@ -75,6 +75,7 @@ codemod
codemods
codeowners
codescene
Combobox
composability
composable
config
@@ -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',
+106
View File
@@ -10,6 +10,7 @@ import type { CheckboxProps as CheckboxProps_2 } from 'react-aria-components';
import { ColumnProps as ColumnProps_2 } from 'react-aria-components';
import type { ColumnSize } from 'react-stately';
import type { ColumnStaticSize } from 'react-stately';
import type { ComboBoxProps } from 'react-aria-components';
import type { ComponentProps } from 'react';
import type { ComponentPropsWithoutRef } from 'react';
import type { ComponentPropsWithRef } from 'react';
@@ -1008,6 +1009,111 @@ export type Columns =
| '12'
| 'auto';
// @public
export const Combobox: ForwardRefExoticComponent<
ComboboxProps & RefAttributes<HTMLDivElement>
>;
// @public
export const ComboboxDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-Combobox';
readonly popover: 'bui-ComboboxPopover';
};
readonly propDefs: {
readonly icon: {};
readonly size: {
readonly dataAttribute: true;
readonly default: 'small';
};
readonly options: {};
readonly placeholder: {};
readonly label: {};
readonly secondaryLabel: {};
readonly description: {};
readonly isRequired: {};
readonly className: {};
};
};
// @public
export const ComboboxInputDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-ComboboxInput';
readonly icon: 'bui-ComboboxInputIcon';
readonly input: 'bui-ComboboxInputField';
readonly chevron: 'bui-ComboboxInputChevron';
};
readonly bg: 'consumer';
readonly propDefs: {
readonly icon: {};
readonly placeholder: {};
};
};
// @public
export const ComboboxListBoxDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-ComboboxList';
readonly noResults: 'bui-ComboboxNoResults';
};
readonly propDefs: {
readonly options: {};
};
};
// @public
export const ComboboxListBoxItemDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-ComboboxItem';
readonly indicator: 'bui-ComboboxItemIndicator';
readonly label: 'bui-ComboboxItemLabel';
};
readonly propDefs: {};
};
// @public (undocumented)
export type ComboboxOwnProps = {
icon?: ReactNode;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
options?: Array<Option_2 | OptionSection>;
placeholder?: string;
label?: FieldLabelProps['label'];
secondaryLabel?: FieldLabelProps['secondaryLabel'];
description?: FieldLabelProps['description'];
isRequired?: boolean;
className?: string;
};
// @public (undocumented)
export interface ComboboxProps
extends ComboboxOwnProps,
Omit<ComboBoxProps<Option_2>, keyof ComboboxOwnProps> {}
// @public
export const ComboboxSectionDefinition: {
readonly styles: {
readonly [key: string]: string;
};
readonly classNames: {
readonly root: 'bui-ComboboxSection';
readonly header: 'bui-ComboboxSectionHeader';
};
readonly propDefs: {};
};
// @public (undocumented)
export interface CompletePaginationOptions extends PaginationOptions {
// (undocumented)
@@ -0,0 +1,253 @@
/*
* 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-Combobox,
.bui-ComboboxPopover {
&[data-size='small'] {
--combobox-item-height: 2rem;
}
&[data-size='medium'] {
--combobox-item-height: 2.5rem;
}
}
.bui-ComboboxPopover {
min-width: var(--trigger-width);
:global(.bui-PopoverContent) {
padding: 0;
}
}
.bui-ComboboxInput {
box-sizing: border-box;
border-radius: var(--bui-radius-3);
border: none;
outline: none;
background-color: var(--bui-bg-neutral-1);
transition: box-shadow 0.2s ease-in-out;
&[data-on-bg='neutral-1'] {
background-color: var(--bui-bg-neutral-2);
}
&[data-on-bg='neutral-2'] {
background-color: var(--bui-bg-neutral-3);
}
&[data-on-bg='neutral-3'] {
background-color: var(--bui-bg-neutral-4);
}
.bui-Combobox[data-focus-within] & {
box-shadow: inset 0 0 0 1px var(--bui-ring);
}
.bui-Combobox[data-invalid] & {
box-shadow: inset 0 0 0 1px var(--bui-border-danger);
}
display: flex;
align-items: center;
gap: var(--bui-space-2);
width: 100%;
height: var(--combobox-item-height);
.bui-Combobox[data-size='small'] & {
padding-inline: var(--bui-space-3) 0;
}
.bui-Combobox[data-size='medium'] & {
padding-inline: var(--bui-space-4) 0;
}
&[data-disabled] {
cursor: not-allowed;
}
}
.bui-ComboboxInputIcon {
display: grid;
place-content: center;
flex-shrink: 0;
color: var(--bui-fg-secondary);
pointer-events: none;
.bui-Combobox[data-size='small'] & svg {
width: 1rem;
height: 1rem;
}
.bui-Combobox[data-size='medium'] & svg {
width: 1.25rem;
height: 1.25rem;
}
}
.bui-ComboboxInputField {
flex: 1;
min-width: 0;
width: 100%;
border: none;
outline: none;
background-color: transparent;
padding: 0;
color: var(--bui-fg-primary);
font-size: var(--bui-font-size-3);
font-family: var(--bui-font-regular);
font-weight: var(--bui-font-weight-regular);
line-height: var(--combobox-item-height);
height: 100%;
text-overflow: ellipsis;
&::placeholder {
color: var(--bui-fg-secondary);
}
&[disabled] {
cursor: not-allowed;
color: var(--bui-fg-disabled);
}
}
.bui-ComboboxInputChevron {
flex-shrink: 0;
flex-grow: 0;
display: grid;
place-content: center;
width: var(--combobox-item-height);
height: var(--combobox-item-height);
background-color: transparent;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
color: var(--bui-fg-secondary);
outline: none;
transition: color 0.2s ease-in-out;
&:hover {
color: var(--bui-fg-primary);
}
& svg {
.bui-Combobox[data-size='small'] & {
width: 1rem;
height: 1rem;
}
.bui-Combobox[data-size='medium'] & {
width: 1.25rem;
height: 1.25rem;
}
}
&[data-disabled] {
cursor: not-allowed;
color: var(--bui-fg-disabled);
}
}
.bui-ComboboxList {
overflow: auto;
min-height: 0;
padding-block: var(--bui-space-1);
padding-inline: var(--bui-space-1);
&[data-focus-visible] {
outline: none;
}
}
.bui-ComboboxItem {
box-sizing: border-box;
position: relative;
display: grid;
grid-template-areas: 'icon text';
grid-template-columns: 1rem 1fr;
align-items: center;
min-height: var(--combobox-item-height);
padding-block: var(--bui-space-1);
padding-left: var(--bui-space-3);
padding-right: var(--bui-space-4);
color: var(--bui-fg-primary);
cursor: pointer;
user-select: none;
font-size: var(--bui-font-size-3);
gap: var(--bui-space-2);
outline: none;
border-radius: var(--bui-radius-2);
&[data-focused] {
background-color: var(--bui-bg-neutral-2);
}
&[data-disabled] {
cursor: not-allowed;
color: var(--bui-fg-disabled);
}
&[data-selected] .bui-ComboboxItemIndicator {
opacity: 1;
}
}
.bui-ComboboxItemIndicator {
grid-area: icon;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.bui-ComboboxItemLabel {
flex: 1;
grid-area: text;
}
.bui-ComboboxSection {
&:first-child .bui-ComboboxSectionHeader {
padding-top: 0;
}
}
.bui-ComboboxSectionHeader {
height: 2rem;
display: flex;
align-items: center;
padding-top: var(--bui-space-3);
padding-left: var(--bui-space-3);
color: var(--bui-fg-primary);
font-size: var(--bui-font-size-1);
font-weight: bold;
letter-spacing: 0.05rem;
text-transform: uppercase;
}
.bui-ComboboxNoResults {
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,307 @@
/*
* 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 preview from '../../../../../.storybook/preview';
import { Combobox } from './Combobox';
import { Flex } from '../Flex';
import { Box } from '../Box';
import { Text } from '../Text';
import { Form } from 'react-aria-components';
import { RiCloudLine } from '@remixicon/react';
const meta = preview.meta({
title: 'Backstage UI/Combobox',
component: Combobox,
args: {
style: { width: 300 },
},
});
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 sectionedOptions = [
{
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 Default = meta.story({
args: {
label: 'Font Family',
options: fontOptions,
placeholder: 'Pick a font',
name: 'font',
},
});
export const WithIcon = meta.story({
args: {
...Default.input.args,
},
render: args => <Combobox {...args} icon={<RiCloudLine />} />,
});
export const WithSections = meta.story({
args: {
label: 'Font Family',
options: sectionedOptions,
placeholder: 'Pick a font',
name: 'font',
},
});
export const AllowsCustomValue = meta.story({
args: {
label: 'Country',
options: countries,
placeholder: 'Type any country',
allowsCustomValue: true,
name: 'country',
},
});
export const Sizes = meta.story({
args: {
...Default.input.args,
},
render: args => (
<Flex direction="row" gap="2">
<Combobox {...args} size="small" icon={<RiCloudLine />} />
<Combobox {...args} size="medium" icon={<RiCloudLine />} />
</Flex>
),
});
export const Required = meta.story({
args: {
...Default.input.args,
isRequired: true,
},
});
export const Disabled = meta.story({
args: {
...Default.input.args,
isDisabled: true,
},
});
export const WithLabelAndDescription = meta.story({
args: {
...Default.input.args,
description: 'Choose a font family for your document',
},
});
export const WithDefaultValue = meta.story({
args: {
...Default.input.args,
defaultValue: 'serif',
},
});
export const WithFullWidth = meta.story({
args: {
...Default.input.args,
style: { width: '100%' },
},
});
export const NoOptions = meta.story({
args: {
...Default.input.args,
options: undefined,
},
});
export const DisabledOption = meta.story({
args: {
...Default.input.args,
disabledKeys: ['cursive', 'serif'],
},
});
export const WithValue = meta.story({
args: {
...Default.input.args,
value: 'mono',
defaultValue: 'serif',
},
});
export const WithError = meta.story({
args: {
...Default.input.args,
},
render: args => (
<Form validationErrors={{ font: 'Invalid font family' }}>
<Combobox {...args} />
</Form>
),
});
export const WithLongNames = meta.story({
args: {
label: 'Document Template',
options: [
{
value: 'annual-report-2024',
label:
'Annual Financial Report and Strategic Planning Document for Fiscal Year 2024 with Comprehensive Analysis of Market Trends, Competitive Landscape, Financial Performance Metrics, Revenue Projections, Cost Optimization Strategies, Risk Assessment, and Long-term Growth Initiatives Across All Business Units and Geographical Regions',
},
{
value: 'product-roadmap',
label:
'Comprehensive Product Development Roadmap and Feature Implementation Timeline Including Detailed Technical Specifications, Resource Allocation Plans, Cross-functional Team Dependencies, Milestone Tracking, Quality Assurance Procedures, User Acceptance Testing Protocols, and Post-launch Support Strategy for All Product Lines and Service Offerings',
},
{
value: 'user-guide',
label:
'Detailed User Guide and Technical Documentation for Advanced System Features Covering Installation Procedures, Configuration Settings, Security Protocols, Troubleshooting Guidelines, Best Practices, Common Use Cases, Performance Optimization Tips, Integration Methods, API Documentation, and Frequently Asked Questions with Step-by-Step Solutions',
},
],
placeholder: 'Select a document template',
name: 'template',
style: { maxWidth: 400 },
defaultValue: 'annual-report-2024',
},
});
export const WithLongNamesAndPadding = meta.story({
args: {
...WithLongNames.input.args,
},
decorators: [
(Story, { args }) => (
<div style={{ padding: 128 }}>
<Story {...args} />
</div>
),
],
});
export const AutoBg = meta.story({
render: () => (
<Flex direction="column" gap="4">
<div style={{ maxWidth: '600px' }}>
Combobox automatically detects its parent bg context and increments the
neutral level by 1. No prop is needed it's fully automatic.
</div>
<Box bg="neutral" p="4">
<Text>Neutral 1 container</Text>
<Flex mt="2" style={{ maxWidth: '300px' }}>
<Combobox options={fontOptions} aria-label="Font family" />
</Flex>
</Box>
<Box bg="neutral">
<Box bg="neutral" p="4">
<Text>Neutral 2 container</Text>
<Flex mt="2" style={{ maxWidth: '300px' }}>
<Combobox options={fontOptions} aria-label="Font family" />
</Flex>
</Box>
</Box>
<Box bg="neutral">
<Box bg="neutral">
<Box bg="neutral" p="4">
<Text>Neutral 3 container</Text>
<Flex mt="2" style={{ maxWidth: '300px' }}>
<Combobox options={fontOptions} aria-label="Font family" />
</Flex>
</Box>
</Box>
</Box>
</Flex>
),
});
export const WithAccessibilityProps = meta.story({
args: {
...Default.input.args,
},
render: args => (
<Flex direction="column" gap="4">
<div>
<h3 style={{ marginBottom: 8 }}>With aria-label</h3>
<Combobox
{...args}
label={undefined}
aria-label="Choose font family"
placeholder="Select a font family"
name="font-aria"
/>
</div>
<div>
<h3 style={{ marginBottom: 8 }}>With aria-labelledby</h3>
<div
id="combobox-font-label"
style={{ marginBottom: 8, fontWeight: 600 }}
>
Font Family Selection
</div>
<Combobox
{...args}
label={undefined}
aria-labelledby="combobox-font-label"
placeholder="Select a font family"
name="font-labelledby"
/>
</div>
</Flex>
),
});
@@ -0,0 +1,92 @@
/*
* 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 { forwardRef, useEffect } from 'react';
import { ComboBox as AriaComboBox } from 'react-aria-components';
import { useFilter } from 'react-aria';
import { ComboboxProps } from './types';
import { useDefinition } from '../../hooks/useDefinition';
import { ComboboxDefinition } from './definition';
import { Popover } from '../Popover';
import { FieldLabel } from '../FieldLabel';
import { FieldError } from '../FieldError';
import { ComboboxInput } from './ComboboxInput';
import { ComboboxListBox } from './ComboboxListBox';
/**
* A text input combined with a dropdown list of options. The user can type to filter
* suggestions, navigate with the keyboard, and pick a value. With `allowsCustomValue`
* the typed text can be committed even if no option matches.
*
* @public
*/
export const Combobox = forwardRef<HTMLDivElement, ComboboxProps>(
(props, ref) => {
const { contains } = useFilter({ sensitivity: 'base' });
const { ownProps, restProps, dataAttributes } = useDefinition(
ComboboxDefinition,
props,
);
const {
classes,
label,
description,
options,
icon,
placeholder,
isRequired,
secondaryLabel,
} = ownProps;
const ariaLabel = restProps['aria-label'];
const ariaLabelledBy = restProps['aria-labelledby'];
useEffect(() => {
if (!label && !ariaLabel && !ariaLabelledBy) {
console.warn(
'Combobox requires either a visible label, aria-label, or aria-labelledby for accessibility',
);
}
}, [label, ariaLabel, ariaLabelledBy]);
const secondaryLabelText =
secondaryLabel || (isRequired ? 'Required' : null);
return (
<AriaComboBox
className={classes.root}
defaultFilter={contains}
{...dataAttributes}
ref={ref}
{...restProps}
>
<FieldLabel
label={label}
secondaryLabel={secondaryLabelText}
description={description}
descriptionSlot="description"
/>
<ComboboxInput icon={icon} placeholder={placeholder} />
<FieldError />
<Popover className={classes.popover} hideArrow {...dataAttributes}>
<ComboboxListBox options={options} />
</Popover>
</AriaComboBox>
);
},
);
Combobox.displayName = 'Combobox';
@@ -0,0 +1,43 @@
/*
* 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 { Button, Group, Input } from 'react-aria-components';
import { RiArrowDownSLine } from '@remixicon/react';
import { useDefinition } from '../../hooks/useDefinition';
import { ComboboxInputDefinition } from './definition';
import type { ComboboxInputOwnProps } from './types';
export function ComboboxInput(props: ComboboxInputOwnProps) {
const { ownProps, dataAttributes } = useDefinition(
ComboboxInputDefinition,
props,
);
const { classes, icon, placeholder } = ownProps;
return (
<Group className={classes.root} {...dataAttributes}>
{icon ? (
<div className={classes.icon} aria-hidden="true">
{icon}
</div>
) : null}
<Input className={classes.input} placeholder={placeholder} />
<Button className={classes.chevron}>
<RiArrowDownSLine aria-hidden="true" />
</Button>
</Group>
);
}
@@ -0,0 +1,90 @@
/*
* 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 {
ListBox,
ListBoxItem,
ListBoxSection,
Header,
Text,
} from 'react-aria-components';
import { RiCheckLine } from '@remixicon/react';
import { useDefinition } from '../../hooks/useDefinition';
import {
ComboboxListBoxDefinition,
ComboboxListBoxItemDefinition,
ComboboxSectionDefinition,
} from './definition';
import type { Option, OptionSection, ComboboxListBoxOwnProps } from './types';
const NoResults = () => {
const { ownProps } = useDefinition(ComboboxListBoxDefinition, {});
const { classes } = ownProps;
return <div className={classes.noResults}>No results found.</div>;
};
function ComboboxItem({ option }: { option: Option }) {
const { ownProps } = useDefinition(ComboboxListBoxItemDefinition, {});
const { classes } = ownProps;
return (
<ListBoxItem
id={option.value}
textValue={option.label}
className={classes.root}
isDisabled={option.disabled}
>
<div className={classes.indicator}>
<RiCheckLine aria-hidden="true" />
</div>
<Text slot="label" className={classes.label}>
{option.label}
</Text>
</ListBoxItem>
);
}
function ComboboxSectionItems({ section }: { section: OptionSection }) {
const { ownProps } = useDefinition(ComboboxSectionDefinition, {});
const { classes } = ownProps;
return (
<ListBoxSection className={classes.root}>
<Header className={classes.header}>{section.title}</Header>
{section.options.map(option => (
<ComboboxItem key={option.value} option={option} />
))}
</ListBoxSection>
);
}
export function ComboboxListBox(props: ComboboxListBoxOwnProps) {
const { ownProps } = useDefinition(ComboboxListBoxDefinition, props);
const { classes, options } = ownProps;
return (
<ListBox className={classes.root} renderEmptyState={() => <NoResults />}>
{options?.map(item =>
'options' in item ? (
<ComboboxSectionItems key={item.title} section={item} />
) : (
<ComboboxItem key={item.value} option={item} />
),
)}
</ListBox>
);
}
@@ -0,0 +1,114 @@
/*
* 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 {
ComboboxOwnProps,
ComboboxInputOwnProps,
ComboboxListBoxOwnProps,
ComboboxListBoxItemOwnProps,
ComboboxSectionOwnProps,
} from './types';
import styles from './Combobox.module.css';
/**
* Component definition for Combobox
* @public
*/
export const ComboboxDefinition = defineComponent<ComboboxOwnProps>()({
styles,
classNames: {
root: 'bui-Combobox',
popover: 'bui-ComboboxPopover',
},
propDefs: {
icon: {},
size: { dataAttribute: true, default: 'small' },
options: {},
placeholder: {},
label: {},
secondaryLabel: {},
description: {},
isRequired: {},
className: {},
},
});
/**
* Component definition for ComboboxInput
* @public
*/
export const ComboboxInputDefinition = defineComponent<ComboboxInputOwnProps>()(
{
styles,
classNames: {
root: 'bui-ComboboxInput',
icon: 'bui-ComboboxInputIcon',
input: 'bui-ComboboxInputField',
chevron: 'bui-ComboboxInputChevron',
},
bg: 'consumer',
propDefs: {
icon: {},
placeholder: {},
},
},
);
/**
* Component definition for ComboboxListBox
* @public
*/
export const ComboboxListBoxDefinition =
defineComponent<ComboboxListBoxOwnProps>()({
styles,
classNames: {
root: 'bui-ComboboxList',
noResults: 'bui-ComboboxNoResults',
},
propDefs: {
options: {},
},
});
/**
* Component definition for ComboboxListBoxItem
* @public
*/
export const ComboboxListBoxItemDefinition =
defineComponent<ComboboxListBoxItemOwnProps>()({
styles,
classNames: {
root: 'bui-ComboboxItem',
indicator: 'bui-ComboboxItemIndicator',
label: 'bui-ComboboxItemLabel',
},
propDefs: {},
});
/**
* Component definition for ComboboxSection
* @public
*/
export const ComboboxSectionDefinition =
defineComponent<ComboboxSectionOwnProps>()({
styles,
classNames: {
root: 'bui-ComboboxSection',
header: 'bui-ComboboxSectionHeader',
},
propDefs: {},
});
@@ -0,0 +1,25 @@
/*
* 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 * from './Combobox';
export * from './types';
export {
ComboboxDefinition,
ComboboxInputDefinition,
ComboboxListBoxDefinition,
ComboboxListBoxItemDefinition,
ComboboxSectionDefinition,
} from './definition';
@@ -0,0 +1,76 @@
/*
* 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 { ReactNode } from 'react';
import type { ComboBoxProps as AriaComboBoxProps } from 'react-aria-components';
import type { Breakpoint } from '../..';
import type { FieldLabelProps } from '../FieldLabel/types';
import type { Option, OptionSection } from '../Select/types';
export type { Option, OptionSection };
/** @public */
export type ComboboxOwnProps = {
/**
* An icon to render before the input
*/
icon?: ReactNode;
/**
* The size of the combobox field
* @defaultValue 'small'
*/
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
/**
* The options of the combobox field. Pass flat options, option sections for
* grouped display, or a mix of both in the same array.
*/
options?: Array<Option | OptionSection>;
/**
* Placeholder text for the input.
*/
placeholder?: string;
label?: FieldLabelProps['label'];
secondaryLabel?: FieldLabelProps['secondaryLabel'];
description?: FieldLabelProps['description'];
isRequired?: boolean;
className?: string;
};
/** @public */
export interface ComboboxProps
extends ComboboxOwnProps,
Omit<AriaComboBoxProps<Option>, keyof ComboboxOwnProps> {}
/** @internal */
export interface ComboboxInputOwnProps {
icon?: ComboboxOwnProps['icon'];
placeholder?: string;
}
/** @internal */
export interface ComboboxListBoxOwnProps {
options?: ComboboxOwnProps['options'];
}
/** @internal */
export type ComboboxListBoxItemOwnProps = {};
/** @internal */
export type ComboboxSectionOwnProps = {};
+1
View File
@@ -35,6 +35,7 @@ export { ButtonLinkDefinition } from './components/ButtonLink/definition';
export { CardDefinition } from './components/Card/definition';
export { CheckboxDefinition } from './components/Checkbox/definition';
export { CheckboxGroupDefinition } from './components/CheckboxGroup/definition';
export { ComboboxDefinition } from './components/Combobox/definition';
export { ContainerDefinition } from './components/Container/definition';
export { DateRangePickerDefinition } from './components/DateRangePicker/definition';
export { DialogDefinition } from './components/Dialog/definition';
+1
View File
@@ -43,6 +43,7 @@ export * from './components/ButtonIcon';
export * from './components/ButtonLink';
export * from './components/Checkbox';
export * from './components/CheckboxGroup';
export * from './components/Combobox';
export * from './components/RadioGroup';
export * from './components/Slider';
export * from './components/Table';