From 816af0fa398d2aca839f49bac68e180f9b33a9f7 Mon Sep 17 00:00:00 2001 From: Johan Persson Date: Tue, 4 Nov 2025 15:04:46 +0100 Subject: [PATCH] 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 --- .changeset/wicked-cycles-enter.md | 9 + docs-ui/src/content/select.mdx | 39 ++++ docs-ui/src/content/select.props.ts | 84 +++++++-- packages/ui/report.api.md | 32 ++-- .../src/components/Select/Select.module.css | 166 +++++++++++++----- .../src/components/Select/Select.stories.tsx | 64 +++++++ packages/ui/src/components/Select/Select.tsx | 68 ++----- .../src/components/Select/SelectContent.tsx | 74 ++++++++ .../src/components/Select/SelectListBox.tsx | 71 ++++++++ .../src/components/Select/SelectTrigger.tsx | 42 +++++ packages/ui/src/components/Select/types.ts | 33 +++- packages/ui/src/utils/componentDefinitions.ts | 6 +- 12 files changed, 556 insertions(+), 132 deletions(-) create mode 100644 .changeset/wicked-cycles-enter.md create mode 100644 packages/ui/src/components/Select/SelectContent.tsx create mode 100644 packages/ui/src/components/Select/SelectListBox.tsx create mode 100644 packages/ui/src/components/Select/SelectTrigger.tsx diff --git a/.changeset/wicked-cycles-enter.md b/.changeset/wicked-cycles-enter.md new file mode 100644 index 0000000000..cea5cff371 --- /dev/null +++ b/.changeset/wicked-cycles-enter.md @@ -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. diff --git a/docs-ui/src/content/select.mdx b/docs-ui/src/content/select.mdx index 09a852e871..eb53e9ea35 100644 --- a/docs-ui/src/content/select.mdx +++ b/docs-ui/src/content/select.mdx @@ -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. + +} + code={selectSearchableSnippet} +/> + +### Multiple Selection + +Here's a view when the select allows multiple selections. + +} + code={selectMultipleSnippet} +/> + +### Searchable with Multiple Selection + +Here's a view when the select combines search and multiple selection. + +} + code={selectSearchableMultipleSnippet} +/> + ### Responsive Here's a view when the select is responsive. diff --git a/docs-ui/src/content/select.props.ts b/docs-ui/src/content/select.props.ts index a93f448bd8..05f0d895c9 100644 --- a/docs-ui/src/content/select.props.ts +++ b/docs-ui/src/content/select.props.ts @@ -23,6 +23,12 @@ export const selectPropDefs: Record = { 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 = { 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 = { }, disabledKeys: { type: 'enum', - values: ['string[]'], + values: ['Iterable'], responsive: false, }, isDisabled: { @@ -72,14 +84,6 @@ export const selectPropDefs: Record = { 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 = { }, 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 = ``; + +export const selectMultipleSnippet = ``; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index c1e1a309cc..9636b5361c 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -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 @@ -1214,22 +1226,18 @@ export interface SearchFieldProps // @public (undocumented) export const Select: ForwardRefExoticComponent< - SelectProps & RefAttributes + SelectProps<'multiple' | 'single'> & RefAttributes >; // @public (undocumented) -export interface SelectProps - extends SelectProps_2<{ - name: string; - value: string; - }>, +export interface SelectProps + extends SelectProps_2, Omit { icon?: ReactNode; - options?: Array<{ - value: string; - label: string; - disabled?: boolean; - }>; + options?: Array; + searchable?: boolean; + searchPlaceholder?: string; + selectionMode?: T; size?: 'small' | 'medium' | Partial>; } diff --git a/packages/ui/src/components/Select/Select.module.css b/packages/ui/src/components/Select/Select.module.css index d4e201ef3a..a2eb41040e 100644 --- a/packages/ui/src/components/Select/Select.module.css +++ b/packages/ui/src/components/Select/Select.module.css @@ -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); + } } diff --git a/packages/ui/src/components/Select/Select.stories.tsx b/packages/ui/src/components/Select/Select.stories.tsx index 6e96e37f08..18263fe710 100644 --- a/packages/ui/src/components/Select/Select.stories.tsx +++ b/packages/ui/src/components/Select/Select.stories.tsx @@ -22,6 +22,9 @@ import { RiCloudLine } from '@remixicon/react'; const meta = { title: 'Backstage UI/Select', component: Select, + args: { + style: { width: 300 }, + }, } satisfies Meta; 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', diff --git a/packages/ui/src/components/Select/Select.tsx b/packages/ui/src/components/Select/Select.tsx index b1fec7dbe7..0b133d07c5 100644 --- a/packages/ui/src/components/Select/Select.tsx +++ b/packages/ui/src/components/Select/Select.tsx @@ -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((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((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((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((props, ref) => { secondaryLabel={secondaryLabelText} description={description} /> - + ((props, ref) => { classNames.popover, styles[classNames.popover], )} + {...dataAttributes} > - - {options?.map(option => ( - -
- -
- - {option.label} - -
- ))} -
+
); diff --git a/packages/ui/src/components/Select/SelectContent.tsx b/packages/ui/src/components/Select/SelectContent.tsx new file mode 100644 index 0000000000..42070321a3 --- /dev/null +++ b/packages/ui/src/components/Select/SelectContent.tsx @@ -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