feat(ui): add searchable and multiple selection support to Select component
Enhanced the Select component with search filtering and multi-selection capabilities through new searchable, selectionMode, and searchPlaceholder props. Breaking changes: - The SelectProps interface now accepts a generic type parameter for selection mode Implementation details: - Created SelectTrigger, SelectContent, and SelectListBox components for modular composition - Integrated react-aria's Autocomplete and SearchField for search functionality - Added support for multiple selection mode - Added "No results found" empty state when search returns no matches - Improved CSS organization and updated visual styling for better consistency - Exported Option type as public API for type safety - Updated API reports and added comprehensive Storybook stories - Added documentation with examples for searchable and multiple selection modes Migration guide: If using SelectProps type directly, update the type parameter: ```diff - SelectProps + SelectProps<'single' | 'multiple'> ``` Component usage remains backward compatible - existing Select implementations require no changes. Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
---
|
||||
'@backstage/ui': minor
|
||||
---
|
||||
|
||||
**BREAKING**: The `SelectProps` interface now accepts a generic type parameter for selection mode.
|
||||
|
||||
Added searchable and multiple selection support to Select component. The component now accepts `searchable`, `selectionMode`, and `searchPlaceholder` props to enable filtering and multi-selection modes.
|
||||
|
||||
Migration: If you're using `SelectProps` type directly, update from `SelectProps` to `SelectProps<'single' | 'multiple'>`. Component usage remains backward compatible.
|
||||
@@ -11,6 +11,9 @@ import {
|
||||
selectDisabledSnippet,
|
||||
selectResponsiveSnippet,
|
||||
selectIconSnippet,
|
||||
selectSearchableSnippet,
|
||||
selectMultipleSnippet,
|
||||
selectSearchableMultipleSnippet,
|
||||
} from './select.props';
|
||||
import { PageTitle } from '@/components/PageTitle';
|
||||
import { Theming } from '@/components/Theming';
|
||||
@@ -87,6 +90,42 @@ Here's a view when the select is disabled.
|
||||
code={selectDisabledSnippet}
|
||||
/>
|
||||
|
||||
### Searchable
|
||||
|
||||
Here's a view when the select has searchable filtering.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<SelectSnippet story="Searchable" />}
|
||||
code={selectSearchableSnippet}
|
||||
/>
|
||||
|
||||
### Multiple Selection
|
||||
|
||||
Here's a view when the select allows multiple selections.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<SelectSnippet story="MultipleSelection" />}
|
||||
code={selectMultipleSnippet}
|
||||
/>
|
||||
|
||||
### Searchable with Multiple Selection
|
||||
|
||||
Here's a view when the select combines search and multiple selection.
|
||||
|
||||
<Snippet
|
||||
align="center"
|
||||
py={4}
|
||||
open
|
||||
preview={<SelectSnippet story="SearchableMultiple" />}
|
||||
code={selectSearchableMultipleSnippet}
|
||||
/>
|
||||
|
||||
### Responsive
|
||||
|
||||
Here's a view when the select is responsive.
|
||||
|
||||
@@ -23,6 +23,12 @@ export const selectPropDefs: Record<string, PropDef> = {
|
||||
values: ['Array<{ value: string, label: string }>'],
|
||||
required: true,
|
||||
},
|
||||
selectionMode: {
|
||||
type: 'enum',
|
||||
values: ['single', 'multiple'],
|
||||
default: 'single',
|
||||
responsive: false,
|
||||
},
|
||||
placeholder: {
|
||||
type: 'string',
|
||||
default: 'Select an item',
|
||||
@@ -34,17 +40,23 @@ export const selectPropDefs: Record<string, PropDef> = {
|
||||
responsive: false,
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
type: 'enum',
|
||||
values: ['string', 'string[]'],
|
||||
responsive: false,
|
||||
description:
|
||||
'Selected value (controlled). String for single selection, array for multiple.',
|
||||
},
|
||||
defaultValue: {
|
||||
type: 'string',
|
||||
type: 'enum',
|
||||
values: ['string', 'string[]'],
|
||||
responsive: false,
|
||||
description:
|
||||
'Initial value (uncontrolled). String for single selection, array for multiple.',
|
||||
},
|
||||
size: {
|
||||
type: 'enum',
|
||||
values: ['small', 'medium'],
|
||||
default: 'medium',
|
||||
default: 'small',
|
||||
responsive: true,
|
||||
},
|
||||
isOpen: {
|
||||
@@ -57,7 +69,7 @@ export const selectPropDefs: Record<string, PropDef> = {
|
||||
},
|
||||
disabledKeys: {
|
||||
type: 'enum',
|
||||
values: ['string[]'],
|
||||
values: ['Iterable<Key>'],
|
||||
responsive: false,
|
||||
},
|
||||
isDisabled: {
|
||||
@@ -72,14 +84,6 @@ export const selectPropDefs: Record<string, PropDef> = {
|
||||
type: 'boolean',
|
||||
responsive: false,
|
||||
},
|
||||
selectedKey: {
|
||||
type: 'string',
|
||||
responsive: false,
|
||||
},
|
||||
defaultSelectedKey: {
|
||||
type: 'string',
|
||||
responsive: false,
|
||||
},
|
||||
onOpenChange: {
|
||||
type: 'enum',
|
||||
values: ['(isOpen: boolean) => void'],
|
||||
@@ -87,7 +91,19 @@ export const selectPropDefs: Record<string, PropDef> = {
|
||||
},
|
||||
onSelectionChange: {
|
||||
type: 'enum',
|
||||
values: ['(key: Key | null) => void'],
|
||||
values: ['(key: Key | null) => void', '(keys: Selection) => void'],
|
||||
responsive: false,
|
||||
description:
|
||||
'Handler called when selection changes. Single mode: receives Key | null. Multiple mode: receives Selection.',
|
||||
},
|
||||
searchable: {
|
||||
type: 'boolean',
|
||||
default: 'false',
|
||||
responsive: false,
|
||||
},
|
||||
searchPlaceholder: {
|
||||
type: 'string',
|
||||
default: 'Search...',
|
||||
responsive: false,
|
||||
},
|
||||
...classNamePropDefs,
|
||||
@@ -151,3 +167,45 @@ export const selectResponsiveSnippet = `<Select
|
||||
label="Font family"
|
||||
options={[ ... ]}
|
||||
/>`;
|
||||
|
||||
export const selectSearchableSnippet = `<Select
|
||||
name="country"
|
||||
label="Country"
|
||||
searchable
|
||||
searchPlaceholder="Search countries..."
|
||||
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 selectMultipleSnippet = `<Select
|
||||
name="options"
|
||||
label="Select multiple options"
|
||||
selectionMode="multiple"
|
||||
options={[
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
{ value: 'option3', label: 'Option 3' },
|
||||
{ value: 'option4', label: 'Option 4' },
|
||||
]}
|
||||
/>`;
|
||||
|
||||
export const selectSearchableMultipleSnippet = `<Select
|
||||
name="skills"
|
||||
label="Skills"
|
||||
searchable
|
||||
selectionMode="multiple"
|
||||
searchPlaceholder="Filter skills..."
|
||||
options={[
|
||||
{ value: 'react', label: 'React' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
// ... more options
|
||||
]}
|
||||
/>`;
|
||||
|
||||
+20
-12
@@ -664,12 +664,16 @@ export const componentDefinitions: {
|
||||
readonly root: 'bui-Select';
|
||||
readonly popover: 'bui-SelectPopover';
|
||||
readonly trigger: 'bui-SelectTrigger';
|
||||
readonly chevron: 'bui-SelectTriggerChevron';
|
||||
readonly value: 'bui-SelectValue';
|
||||
readonly icon: 'bui-SelectIcon';
|
||||
readonly list: 'bui-SelectList';
|
||||
readonly item: 'bui-SelectItem';
|
||||
readonly itemIndicator: 'bui-SelectItemIndicator';
|
||||
readonly itemLabel: 'bui-SelectItemLabel';
|
||||
readonly searchWrapper: 'bui-SelectSearchWrapper';
|
||||
readonly search: 'bui-SelectSearch';
|
||||
readonly searchClear: 'bui-SelectSearchClear';
|
||||
readonly noResults: 'bui-SelectNoResults';
|
||||
};
|
||||
readonly dataAttributes: {
|
||||
readonly size: readonly ['small', 'medium'];
|
||||
@@ -1170,6 +1174,14 @@ export const MenuTrigger: (props: MenuTriggerProps) => JSX_2.Element;
|
||||
// @public (undocumented)
|
||||
export interface MenuTriggerProps extends MenuTriggerProps_2 {}
|
||||
|
||||
// @public (undocumented)
|
||||
type Option_2 = {
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
export { Option_2 as Option };
|
||||
|
||||
// @public (undocumented)
|
||||
export const Radio: ForwardRefExoticComponent<
|
||||
RadioProps & RefAttributes<HTMLLabelElement>
|
||||
@@ -1214,22 +1226,18 @@ export interface SearchFieldProps
|
||||
|
||||
// @public (undocumented)
|
||||
export const Select: ForwardRefExoticComponent<
|
||||
SelectProps & RefAttributes<HTMLDivElement>
|
||||
SelectProps<'multiple' | 'single'> & RefAttributes<HTMLDivElement>
|
||||
>;
|
||||
|
||||
// @public (undocumented)
|
||||
export interface SelectProps
|
||||
extends SelectProps_2<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>,
|
||||
export interface SelectProps<T extends 'single' | 'multiple'>
|
||||
extends SelectProps_2<Option_2, T>,
|
||||
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
|
||||
icon?: ReactNode;
|
||||
options?: Array<{
|
||||
value: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
}>;
|
||||
options?: Array<Option_2>;
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
selectionMode?: T;
|
||||
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,17 @@
|
||||
@layer tokens, base, components, utilities;
|
||||
|
||||
@layer components {
|
||||
.bui-Select,
|
||||
.bui-SelectPopover {
|
||||
&[data-size='small'] {
|
||||
--select-item-height: 2rem;
|
||||
}
|
||||
|
||||
&[data-size='medium'] {
|
||||
--select-item-height: 2.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SelectPopover {
|
||||
min-width: var(--trigger-width);
|
||||
}
|
||||
@@ -32,30 +43,29 @@
|
||||
cursor: pointer;
|
||||
gap: var(--bui-space-2);
|
||||
width: 100%;
|
||||
height: var(--select-item-height);
|
||||
|
||||
.bui-Select[data-size='small'] & {
|
||||
padding-inline: var(--bui-space-3) 0;
|
||||
}
|
||||
|
||||
.bui-Select[data-size='medium'] & {
|
||||
padding-inline: var(--bui-space-4) 0;
|
||||
}
|
||||
|
||||
& svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--bui-fg-secondary);
|
||||
}
|
||||
|
||||
&[data-size='small'] {
|
||||
height: 2rem;
|
||||
padding-inline: var(--bui-space-3);
|
||||
}
|
||||
.bui-Select[data-size='small'] & {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&[data-size='medium'] {
|
||||
height: 3rem;
|
||||
padding-inline: var(--bui-space-4);
|
||||
}
|
||||
|
||||
&[data-size='small'] svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
&[data-size='medium'] svg {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
.bui-Select[data-size='medium'] & {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
@@ -63,7 +73,7 @@
|
||||
}
|
||||
|
||||
&:hover {
|
||||
transition: border-color 0.2s ease-in-out, outline-color 0.2s ease-in-out;
|
||||
transition: border-color 0.2s ease-in-out;
|
||||
border-color: var(--bui-border-hover);
|
||||
}
|
||||
|
||||
@@ -72,16 +82,13 @@
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
.bui-Select[data-invalid] &,
|
||||
&[data-invalid] {
|
||||
.bui-Select[data-invalid] & {
|
||||
border-color: var(--bui-fg-danger);
|
||||
}
|
||||
&[data-invalid]:hover {
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
&[data-invalid]:focus-visible {
|
||||
border-width: 2px;
|
||||
&:focus-visible,
|
||||
&:hover {
|
||||
outline: 1px solid var(--bui-fg-danger);
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] {
|
||||
@@ -89,14 +96,15 @@
|
||||
border-color: var(--bui-border-disabled);
|
||||
color: var(--bui-fg-disabled);
|
||||
}
|
||||
}
|
||||
|
||||
&[disabled] .bui-SelectValue {
|
||||
color: var(--bui-fg-disabled);
|
||||
}
|
||||
|
||||
&[data-popup-open] .bui-SelectIcon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.bui-SelectTriggerChevron {
|
||||
display: grid;
|
||||
place-content: center;
|
||||
width: var(--select-item-height);
|
||||
height: var(--select-item-height);
|
||||
flex-shrink: 0;
|
||||
flex-grow: 0;
|
||||
}
|
||||
|
||||
.bui-SelectValue {
|
||||
@@ -128,37 +136,32 @@
|
||||
}
|
||||
|
||||
.bui-SelectItem {
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
width: var(--anchor-width);
|
||||
display: grid;
|
||||
grid-template-areas: 'icon text';
|
||||
grid-template-columns: 1rem 1fr;
|
||||
align-items: center;
|
||||
padding-block: var(--bui-space-2);
|
||||
min-height: var(--select-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);
|
||||
border-radius: var(--bui-radius-3);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: var(--bui-font-size-3);
|
||||
gap: var(--bui-space-1);
|
||||
gap: var(--bui-space-2);
|
||||
outline: none;
|
||||
|
||||
&[data-focused] {
|
||||
z-index: 0;
|
||||
position: relative;
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
&[data-focused]::before {
|
||||
content: '';
|
||||
z-index: -1;
|
||||
position: absolute;
|
||||
inset-block: 0;
|
||||
inset-inline: 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
background-color: var(--bui-bg-tint-hover);
|
||||
inset-inline: var(--bui-space-1);
|
||||
border-radius: var(--bui-radius-2);
|
||||
background: var(--bui-bg-surface-2);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
&[data-disabled] {
|
||||
@@ -184,4 +187,73 @@
|
||||
flex: 1;
|
||||
grid-area: text;
|
||||
}
|
||||
|
||||
.bui-SelectSearchWrapper {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: var(--bui-space-1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-inline: var(--bui-space-3) 0;
|
||||
border-bottom: 1px solid var(--bui-border);
|
||||
}
|
||||
|
||||
.bui-SelectSearch {
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
color: var(--bui-fg-primary);
|
||||
flex: 1;
|
||||
outline: none;
|
||||
font-size: var(--bui-font-size-3);
|
||||
font-family: var(--bui-font-regular);
|
||||
height: var(--select-item-height);
|
||||
line-height: var(--select-item-height);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--bui-fg-secondary);
|
||||
}
|
||||
|
||||
/* Hide native browser clear button */
|
||||
&::-webkit-search-cancel-button,
|
||||
&::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SelectSearchClear {
|
||||
flex: 0 0 auto;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
color: var(--bui-fg-secondary);
|
||||
transition: color 0.2s ease-in-out;
|
||||
width: var(--select-item-height);
|
||||
height: var(--select-item-height);
|
||||
|
||||
input:placeholder-shown + & {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--bui-fg-primary);
|
||||
}
|
||||
|
||||
& svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
.bui-SelectNoResults {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,9 @@ import { RiCloudLine } from '@remixicon/react';
|
||||
const meta = {
|
||||
title: 'Backstage UI/Select',
|
||||
component: Select,
|
||||
args: {
|
||||
style: { width: 300 },
|
||||
},
|
||||
} satisfies Meta<typeof Select>;
|
||||
|
||||
export default meta;
|
||||
@@ -34,6 +37,35 @@ const fontOptions = [
|
||||
{ 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 skills = [
|
||||
{ value: 'react', label: 'React' },
|
||||
{ value: 'typescript', label: 'TypeScript' },
|
||||
{ value: 'javascript', label: 'JavaScript' },
|
||||
{ value: 'python', label: 'Python' },
|
||||
{ value: 'java', label: 'Java' },
|
||||
{ value: 'csharp', label: 'C#' },
|
||||
{ value: 'go', label: 'Go' },
|
||||
{ value: 'rust', label: 'Rust' },
|
||||
{ value: 'kotlin', label: 'Kotlin' },
|
||||
{ value: 'swift', label: 'Swift' },
|
||||
];
|
||||
|
||||
export const Default: Story = {
|
||||
args: {
|
||||
options: fontOptions,
|
||||
@@ -41,6 +73,38 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const Searchable: Story = {
|
||||
args: {
|
||||
label: 'Country',
|
||||
searchable: true,
|
||||
searchPlaceholder: 'Search countries...',
|
||||
options: countries,
|
||||
},
|
||||
};
|
||||
|
||||
export const MultipleSelection: Story = {
|
||||
args: {
|
||||
label: 'Select multiple options',
|
||||
selectionMode: 'multiple',
|
||||
options: [
|
||||
{ value: 'option1', label: 'Option 1' },
|
||||
{ value: 'option2', label: 'Option 2' },
|
||||
{ value: 'option3', label: 'Option 3' },
|
||||
{ value: 'option4', label: 'Option 4' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export const SearchableMultiple: Story = {
|
||||
args: {
|
||||
label: 'Skills',
|
||||
searchable: true,
|
||||
selectionMode: 'multiple',
|
||||
searchPlaceholder: 'Filter skills...',
|
||||
options: skills,
|
||||
},
|
||||
};
|
||||
|
||||
export const Preview: Story = {
|
||||
args: {
|
||||
label: 'Font Family',
|
||||
|
||||
@@ -15,15 +15,7 @@
|
||||
*/
|
||||
|
||||
import { forwardRef, useEffect } from 'react';
|
||||
import {
|
||||
Select as AriaSelect,
|
||||
SelectValue,
|
||||
Button,
|
||||
Popover,
|
||||
ListBox,
|
||||
ListBoxItem,
|
||||
Text,
|
||||
} from 'react-aria-components';
|
||||
import { Select as AriaSelect, Popover } from 'react-aria-components';
|
||||
import clsx from 'clsx';
|
||||
import { SelectProps } from './types';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
@@ -31,10 +23,14 @@ import { FieldLabel } from '../FieldLabel';
|
||||
import { FieldError } from '../FieldError';
|
||||
import styles from './Select.module.css';
|
||||
import stylesPopover from '../Popover/Popover.module.css';
|
||||
import { RiArrowDownSLine, RiCheckLine } from '@remixicon/react';
|
||||
import { SelectTrigger } from './SelectTrigger';
|
||||
import { SelectContent } from './SelectContent';
|
||||
|
||||
/** @public */
|
||||
export const Select = forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
|
||||
export const Select = forwardRef<
|
||||
HTMLDivElement,
|
||||
SelectProps<'single' | 'multiple'>
|
||||
>((props, ref) => {
|
||||
const { classNames: popoverClassNames } = useStyles('Popover');
|
||||
const { classNames, dataAttributes, cleanedProps } = useStyles('Select', {
|
||||
size: 'small',
|
||||
@@ -47,14 +43,13 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
|
||||
label,
|
||||
description,
|
||||
options,
|
||||
placeholder,
|
||||
size,
|
||||
icon,
|
||||
searchable,
|
||||
searchPlaceholder,
|
||||
'aria-label': ariaLabel,
|
||||
'aria-labelledby': ariaLabelledBy,
|
||||
isRequired,
|
||||
secondaryLabel,
|
||||
style,
|
||||
...rest
|
||||
} = cleanedProps;
|
||||
|
||||
@@ -66,7 +61,6 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
|
||||
}
|
||||
}, [label, ariaLabel, ariaLabelledBy]);
|
||||
|
||||
// If a secondary label is provided, use it. Otherwise, use 'Required' if the field is required.
|
||||
const secondaryLabelText = secondaryLabel || (isRequired ? 'Required' : null);
|
||||
|
||||
return (
|
||||
@@ -83,16 +77,7 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
|
||||
secondaryLabel={secondaryLabelText}
|
||||
description={description}
|
||||
/>
|
||||
<Button
|
||||
className={clsx(classNames.trigger, styles[classNames.trigger])}
|
||||
data-size={dataAttributes['data-size']}
|
||||
>
|
||||
{icon}
|
||||
<SelectValue
|
||||
className={clsx(classNames.value, styles[classNames.value])}
|
||||
/>
|
||||
<RiArrowDownSLine aria-hidden="true" />
|
||||
</Button>
|
||||
<SelectTrigger icon={icon} />
|
||||
<FieldError />
|
||||
<Popover
|
||||
className={clsx(
|
||||
@@ -101,34 +86,13 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>((props, ref) => {
|
||||
classNames.popover,
|
||||
styles[classNames.popover],
|
||||
)}
|
||||
{...dataAttributes}
|
||||
>
|
||||
<ListBox className={clsx(classNames.list, styles[classNames.list])}>
|
||||
{options?.map(option => (
|
||||
<ListBoxItem
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
className={clsx(classNames.item, styles[classNames.item])}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
classNames.itemIndicator,
|
||||
styles[classNames.itemIndicator],
|
||||
)}
|
||||
>
|
||||
<RiCheckLine />
|
||||
</div>
|
||||
<Text
|
||||
slot="label"
|
||||
className={clsx(
|
||||
classNames.itemLabel,
|
||||
styles[classNames.itemLabel],
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
<SelectContent
|
||||
searchable={searchable}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
options={options}
|
||||
/>
|
||||
</Popover>
|
||||
</AriaSelect>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Copyright 2025 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 {
|
||||
Input,
|
||||
SearchField,
|
||||
Autocomplete,
|
||||
Button,
|
||||
} from 'react-aria-components';
|
||||
import { useFilter } from 'react-aria';
|
||||
import { RiCloseCircleLine } from '@remixicon/react';
|
||||
import clsx from 'clsx';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import { SelectListBox } from './SelectListBox';
|
||||
import styles from './Select.module.css';
|
||||
import type { Option } from './types';
|
||||
|
||||
interface SelectContentProps {
|
||||
searchable?: boolean;
|
||||
searchPlaceholder?: string;
|
||||
options?: Array<Option>;
|
||||
}
|
||||
|
||||
export function SelectContent({
|
||||
searchable,
|
||||
searchPlaceholder = 'Search...',
|
||||
options,
|
||||
}: SelectContentProps) {
|
||||
const { contains } = useFilter({ sensitivity: 'base' });
|
||||
const { classNames } = useStyles('Select');
|
||||
|
||||
if (!searchable) {
|
||||
return <SelectListBox options={options} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Autocomplete filter={contains}>
|
||||
<SearchField
|
||||
autoFocus
|
||||
className={clsx(
|
||||
classNames.searchWrapper,
|
||||
styles[classNames.searchWrapper],
|
||||
)}
|
||||
>
|
||||
<Input
|
||||
placeholder={searchPlaceholder}
|
||||
className={clsx(classNames.search, styles[classNames.search])}
|
||||
/>
|
||||
<Button
|
||||
className={clsx(
|
||||
classNames.searchClear,
|
||||
styles[classNames.searchClear],
|
||||
)}
|
||||
>
|
||||
<RiCloseCircleLine />
|
||||
</Button>
|
||||
</SearchField>
|
||||
<SelectListBox options={options} />
|
||||
</Autocomplete>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/*
|
||||
* Copyright 2025 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, Text } from 'react-aria-components';
|
||||
import { RiCheckLine } from '@remixicon/react';
|
||||
import clsx from 'clsx';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import styles from './Select.module.css';
|
||||
import type { Option } from './types';
|
||||
|
||||
interface SelectListBoxProps {
|
||||
options?: Array<Option>;
|
||||
}
|
||||
|
||||
const NoResults = () => {
|
||||
const { classNames } = useStyles('Select');
|
||||
|
||||
return (
|
||||
<div className={clsx(classNames.noResults, styles[classNames.noResults])}>
|
||||
No results found.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function SelectListBox({ options, ...props }: SelectListBoxProps) {
|
||||
const { classNames } = useStyles('Select', props);
|
||||
return (
|
||||
<ListBox
|
||||
className={clsx(classNames.list, styles[classNames.list])}
|
||||
renderEmptyState={() => <NoResults />}
|
||||
>
|
||||
{options?.map(option => (
|
||||
<ListBoxItem
|
||||
key={option.value}
|
||||
id={option.value}
|
||||
textValue={option.label}
|
||||
className={clsx(classNames.item, styles[classNames.item])}
|
||||
isDisabled={option.disabled}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
classNames.itemIndicator,
|
||||
styles[classNames.itemIndicator],
|
||||
)}
|
||||
>
|
||||
<RiCheckLine />
|
||||
</div>
|
||||
<Text
|
||||
slot="label"
|
||||
className={clsx(classNames.itemLabel, styles[classNames.itemLabel])}
|
||||
>
|
||||
{option.label}
|
||||
</Text>
|
||||
</ListBoxItem>
|
||||
))}
|
||||
</ListBox>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2025 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 { Button, SelectValue } from 'react-aria-components';
|
||||
import { RiArrowDownSLine } from '@remixicon/react';
|
||||
import clsx from 'clsx';
|
||||
import { useStyles } from '../../hooks/useStyles';
|
||||
import styles from './Select.module.css';
|
||||
|
||||
interface SelectTriggerProps {
|
||||
icon?: ReactNode;
|
||||
}
|
||||
|
||||
export function SelectTrigger({ icon }: SelectTriggerProps) {
|
||||
const { classNames } = useStyles('Select');
|
||||
|
||||
return (
|
||||
<Button className={clsx(classNames.trigger, styles[classNames.trigger])}>
|
||||
{icon}
|
||||
<SelectValue
|
||||
className={clsx(classNames.value, styles[classNames.value])}
|
||||
/>
|
||||
<div className={clsx(classNames.chevron, styles[classNames.chevron])}>
|
||||
<RiArrowDownSLine aria-hidden="true" />
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -20,11 +20,11 @@ import type { SelectProps as AriaSelectProps } from 'react-aria-components';
|
||||
import type { FieldLabelProps } from '../FieldLabel/types';
|
||||
|
||||
/** @public */
|
||||
export interface SelectProps
|
||||
extends AriaSelectProps<{
|
||||
name: string;
|
||||
value: string;
|
||||
}>,
|
||||
export type Option = { value: string; label: string; disabled?: boolean };
|
||||
|
||||
/** @public */
|
||||
export interface SelectProps<T extends 'single' | 'multiple'>
|
||||
extends AriaSelectProps<Option, T>,
|
||||
Omit<FieldLabelProps, 'htmlFor' | 'id' | 'className'> {
|
||||
/**
|
||||
* An icon to render before the input
|
||||
@@ -33,12 +33,31 @@ export interface SelectProps
|
||||
|
||||
/**
|
||||
* The size of the select field
|
||||
* @defaultValue 'medium'
|
||||
* @defaultValue 'small'
|
||||
*/
|
||||
size?: 'small' | 'medium' | Partial<Record<Breakpoint, 'small' | 'medium'>>;
|
||||
|
||||
/**
|
||||
* The options of the select field
|
||||
*/
|
||||
options?: Array<{ value: string; label: string; disabled?: boolean }>;
|
||||
options?: Array<Option>;
|
||||
|
||||
/**
|
||||
* Enable search/filter functionality in the dropdown
|
||||
* @defaultValue false
|
||||
*/
|
||||
searchable?: boolean;
|
||||
|
||||
/**
|
||||
* placeholder text for the search input
|
||||
* only used when searchable is true
|
||||
* @defaultvalue 'search...'
|
||||
*/
|
||||
searchPlaceholder?: string;
|
||||
|
||||
/**
|
||||
* Selection mode, single or multiple
|
||||
* @defaultvalue 'single'
|
||||
*/
|
||||
selectionMode?: T;
|
||||
}
|
||||
|
||||
@@ -303,12 +303,16 @@ export const componentDefinitions = {
|
||||
root: 'bui-Select',
|
||||
popover: 'bui-SelectPopover',
|
||||
trigger: 'bui-SelectTrigger',
|
||||
chevron: 'bui-SelectTriggerChevron',
|
||||
value: 'bui-SelectValue',
|
||||
icon: 'bui-SelectIcon',
|
||||
list: 'bui-SelectList',
|
||||
item: 'bui-SelectItem',
|
||||
itemIndicator: 'bui-SelectItemIndicator',
|
||||
itemLabel: 'bui-SelectItemLabel',
|
||||
searchWrapper: 'bui-SelectSearchWrapper',
|
||||
search: 'bui-SelectSearch',
|
||||
searchClear: 'bui-SelectSearchClear',
|
||||
noResults: 'bui-SelectNoResults',
|
||||
},
|
||||
dataAttributes: {
|
||||
size: ['small', 'medium'] as const,
|
||||
|
||||
Reference in New Issue
Block a user