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:
@@ -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 = {};
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user