Add support for sections to Select (#34012)

Updates the Select component to accept a set of sections with options as opposed to just a flat list of options.

---------

Signed-off-by: James Brooks <jamesbrooks@spotify.com>
This commit is contained in:
James Brooks
2026-04-28 16:13:39 +01:00
committed by GitHub
parent 080dcbb982
commit e7fc79fb13
12 changed files with 357 additions and 51 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added support for grouping options into sections in the Select component. You can now pass section objects with a `title` and a nested `options` array alongside (or instead of) regular options to render grouped dropdowns with section headers.
**Affected components:** Select
@@ -40,6 +40,33 @@ const skills = [
{ value: 'swift', label: 'Swift' },
];
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 = () => (
<Select
label="Font Family"
@@ -148,3 +175,23 @@ export const SearchableMultiple = () => (
style={{ width: 300 }}
/>
);
export const WithSections = () => (
<Select
label="Font Family"
options={sectionedFonts}
name="font"
style={{ width: 300 }}
/>
);
export const SearchableWithSections = () => (
<Select
label="Font Family"
searchable
searchPlaceholder="Search fonts..."
options={sectionedFonts}
name="font"
style={{ width: 300 }}
/>
);
+44 -1
View File
@@ -12,8 +12,14 @@ import {
Searchable,
MultipleSelection,
SearchableMultiple,
WithSections,
SearchableWithSections,
} from './components';
import { selectPropDefs } from './props-definition';
import {
selectPropDefs,
optionPropDefs,
optionSectionPropDefs,
} from './props-definition';
import {
selectUsageSnippet,
selectDefaultSnippet,
@@ -26,6 +32,8 @@ import {
selectMultipleSnippet,
selectSearchableMultipleSnippet,
selectDisabledOptionsSnippet,
selectSectionsSnippet,
selectSearchableSectionsSnippet,
} from './snippets';
import { PageTitle } from '@/components/PageTitle';
import { Theming } from '@/components/Theming';
@@ -58,6 +66,18 @@ export const reactAriaUrls = {
<ReactAriaLink component="Select" href={reactAriaUrls.select} />
### 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
@@ -136,6 +156,29 @@ Combine search and multiple selection.
code={selectSearchableMultipleSnippet}
/>
### 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={selectSectionsSnippet}
/>
### Searchable with sections
Sections are preserved when filtering with `searchable`.
<Snippet
layout="side-by-side"
open
preview={<SearchableWithSections />}
code={selectSearchableSectionsSnippet}
/>
### Responsive
Size can change at different breakpoints.
@@ -5,30 +5,48 @@ import {
} from '@/utils/propDefs';
import { Chip } from '@/components/Chip';
export const optionPropDefs: Record<string, PropDef> = {
value: {
type: 'string',
required: true,
description: 'Unique value for the option.',
},
label: {
type: 'string',
required: true,
description: 'Display text for the option.',
},
disabled: {
type: 'boolean',
description: 'Whether the option is disabled.',
},
};
export const optionSectionPropDefs: Record<string, PropDef> = {
title: {
type: 'string',
required: true,
description: 'Heading displayed above the grouped options.',
},
options: {
type: 'enum',
values: ['Option[]'],
required: true,
description: 'Options nested inside the section.',
},
};
export const selectPropDefs: Record<string, PropDef> = {
options: {
type: 'complex',
description: 'Array of options to display in the dropdown.',
complexType: {
name: 'SelectOption[]',
properties: {
value: {
type: 'string',
required: true,
description: 'Unique value for the option.',
},
label: {
type: 'string',
required: true,
description: 'Display text for the option.',
},
disabled: {
type: 'boolean',
required: false,
description: 'Whether the option is disabled.',
},
},
},
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.
</>
),
},
selectionMode: {
type: 'enum',
@@ -110,3 +110,49 @@ export const selectDisabledOptionsSnippet = `<Select
{ value: 'cursive', label: 'Cursive' },
]}
/>`;
export const selectSectionsSnippet = `<Select
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' },
],
},
]}
/>`;
export const selectSearchableSectionsSnippet = `<Select
name="font"
label="Font Family"
searchable
searchPlaceholder="Search fonts..."
options={[
{
title: 'Serif Fonts',
options: [
{ value: 'times', label: 'Times New Roman' },
{ value: 'georgia', label: 'Georgia' },
],
},
{
title: 'Sans-Serif Fonts',
options: [
{ value: 'arial', label: 'Arial' },
{ value: 'helvetica', label: 'Helvetica' },
],
},
]}
/>`;
+7 -1
View File
@@ -2181,6 +2181,12 @@ type Option_2 = {
};
export { Option_2 as Option };
// @public (undocumented)
export type OptionSection = {
title: string;
options: Option_2[];
};
// @public (undocumented)
export interface PaddingProps {
// (undocumented)
@@ -2667,7 +2673,7 @@ export const SelectDefinition: {
export type SelectOwnProps = {
icon?: ReactNode;
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
options?: Array<Option_2>;
options?: Array<Option_2 | OptionSection>;
searchable?: boolean;
searchPlaceholder?: string;
label?: FieldLabelProps['label'];
@@ -251,6 +251,25 @@
}
}
.bui-SelectSection {
&:first-child .bui-SelectSectionHeader {
padding-top: 0;
}
}
.bui-SelectSectionHeader {
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-SelectNoResults {
padding-inline: var(--bui-space-3);
padding-block: var(--bui-space-2);
@@ -105,6 +105,51 @@ export const SearchableMultiple = meta.story({
},
});
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 WithSections = meta.story({
args: {
label: 'Font Family',
options: sectionedOptions,
name: 'font',
},
});
export const SearchableWithSections = meta.story({
args: {
label: 'Font Family',
searchable: true,
searchPlaceholder: 'Search fonts...',
options: sectionedOptions,
name: 'font',
},
});
export const Preview = meta.story({
args: {
label: 'Font Family',
@@ -25,12 +25,12 @@ import { RiCloseCircleLine } from '@remixicon/react';
import { useDefinition } from '../../hooks/useDefinition';
import { SelectContentDefinition } from './definition';
import { SelectListBox } from './SelectListBox';
import type { Option } from './types';
import type { SelectOwnProps } from './types';
interface SelectContentProps {
searchable?: boolean;
searchPlaceholder?: string;
options?: Array<Option>;
options?: SelectOwnProps['options'];
}
export function SelectContent(props: SelectContentProps) {
@@ -14,14 +14,24 @@
* limitations under the License.
*/
import { ListBox, ListBoxItem, Text } from 'react-aria-components';
import {
ListBox,
ListBoxItem,
ListBoxSection,
Header,
Text,
} from 'react-aria-components';
import { RiCheckLine } from '@remixicon/react';
import { useDefinition } from '../../hooks/useDefinition';
import { SelectListBoxDefinition } from './definition';
import type { Option } from './types';
import {
SelectListBoxDefinition,
SelectListBoxItemDefinition,
SelectSectionDefinition,
} from './definition';
import type { Option, OptionSection, SelectOwnProps } from './types';
interface SelectListBoxProps {
options?: Array<Option>;
options?: SelectOwnProps['options'];
}
const NoResults = () => {
@@ -31,28 +41,54 @@ const NoResults = () => {
return <div className={classes.noResults}>No results found.</div>;
};
function SelectItem({ option }: { option: Option }) {
const { ownProps } = useDefinition(SelectListBoxItemDefinition, {});
const { classes } = ownProps;
return (
<ListBoxItem
id={option.value}
textValue={option.label}
className={classes.root}
isDisabled={option.disabled}
>
<div className={classes.indicator}>
<RiCheckLine />
</div>
<Text slot="label" className={classes.label}>
{option.label}
</Text>
</ListBoxItem>
);
}
function SelectSectionItems({ section }: { section: OptionSection }) {
const { ownProps } = useDefinition(SelectSectionDefinition, {});
const { classes } = ownProps;
return (
<ListBoxSection className={classes.root}>
<Header className={classes.header}>{section.title}</Header>
{section.options.map(option => (
<SelectItem key={option.value} option={option} />
))}
</ListBoxSection>
);
}
export function SelectListBox(props: SelectListBoxProps) {
const { ownProps } = useDefinition(SelectListBoxDefinition, props);
const { classes, options } = ownProps;
return (
<ListBox className={classes.root} renderEmptyState={() => <NoResults />}>
{options?.map(option => (
<ListBoxItem
key={option.value}
id={option.value}
textValue={option.label}
className={classes.item}
isDisabled={option.disabled}
>
<div className={classes.itemIndicator}>
<RiCheckLine />
</div>
<Text slot="label" className={classes.itemLabel}>
{option.label}
</Text>
</ListBoxItem>
))}
{options?.map(item =>
'options' in item ? (
<SelectSectionItems key={item.title} section={item} />
) : (
<SelectItem key={item.value} option={item} />
),
)}
</ListBox>
);
}
@@ -20,6 +20,8 @@ import type {
SelectTriggerOwnProps,
SelectContentOwnProps,
SelectListBoxOwnProps,
SelectListBoxItemOwnProps,
SelectSectionOwnProps,
} from './types';
import styles from './Select.module.css';
@@ -95,9 +97,6 @@ export const SelectListBoxDefinition = defineComponent<SelectListBoxOwnProps>()(
styles,
classNames: {
root: 'bui-SelectList',
item: 'bui-SelectItem',
itemIndicator: 'bui-SelectItemIndicator',
itemLabel: 'bui-SelectItemLabel',
noResults: 'bui-SelectNoResults',
},
propDefs: {
@@ -105,3 +104,33 @@ export const SelectListBoxDefinition = defineComponent<SelectListBoxOwnProps>()(
},
},
);
/**
* Component definition for SelectListBoxItem
* @internal
*/
export const SelectListBoxItemDefinition =
defineComponent<SelectListBoxItemOwnProps>()({
styles,
classNames: {
root: 'bui-SelectItem',
indicator: 'bui-SelectItemIndicator',
label: 'bui-SelectItemLabel',
},
propDefs: {},
});
/**
* Component definition for SelectSection
* @internal
*/
export const SelectSectionDefinition = defineComponent<SelectSectionOwnProps>()(
{
styles,
classNames: {
root: 'bui-SelectSection',
header: 'bui-SelectSectionHeader',
},
propDefs: {},
},
);
+12 -2
View File
@@ -22,6 +22,9 @@ import type { FieldLabelProps } from '../FieldLabel/types';
/** @public */
export type Option = { value: string; label: string; disabled?: boolean };
/** @public */
export type OptionSection = { title: string; options: Option[] };
/** @public */
export type SelectOwnProps = {
/**
@@ -36,9 +39,10 @@ export type SelectOwnProps = {
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
/**
* The options of the select field
* The options of the select field. Pass flat options, option sections for
* grouped display, or a mix of both in the same array.
*/
options?: Array<Option>;
options?: Array<Option | OptionSection>;
/**
* Enable search/filter functionality in the dropdown
@@ -87,3 +91,9 @@ export interface SelectContentOwnProps {
export interface SelectListBoxOwnProps {
options?: SelectOwnProps['options'];
}
/** @internal */
export type SelectListBoxItemOwnProps = {};
/** @internal */
export type SelectSectionOwnProps = {};