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