diff --git a/.changeset/whole-bees-wave.md b/.changeset/whole-bees-wave.md new file mode 100644 index 0000000000..34a4b6b9c4 --- /dev/null +++ b/.changeset/whole-bees-wave.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Added a new `Combobox` component. It pairs a text input with a filterable dropdown of options and supports single selection, sectioned options, icons, sizes, and custom typed values via `allowsCustomValue`. + +**Affected components:** Combobox diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 4bf2b7b476..40cff3ea5f 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -75,6 +75,7 @@ codemod codemods codeowners codescene +Combobox composability composable config diff --git a/docs-ui/src/app/components/combobox/components.tsx b/docs-ui/src/app/components/combobox/components.tsx new file mode 100644 index 0000000000..74c6799dc7 --- /dev/null +++ b/docs-ui/src/app/components/combobox/components.tsx @@ -0,0 +1,151 @@ +'use client'; + +import { Combobox } from '../../../../../packages/ui/src/components/Combobox/Combobox'; +import { Flex } from '../../../../../packages/ui/src/components/Flex/Flex'; +import { RiCloudLine } from '@remixicon/react'; + +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 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 = () => ( + +); + +export const WithLabelAndDescription = () => ( + +); + +export const Sizes = () => ( + + + + +); + +export const WithIcon = () => ( + } + style={{ width: 300 }} + /> +); + +export const Disabled = () => ( + +); + +export const AllowsCustomValue = () => ( + +); + +export const DisabledOption = () => ( + +); + +export const WithSections = () => ( + +); diff --git a/docs-ui/src/app/components/combobox/page.mdx b/docs-ui/src/app/components/combobox/page.mdx new file mode 100644 index 0000000000..d579e1ffbb --- /dev/null +++ b/docs-ui/src/app/components/combobox/page.mdx @@ -0,0 +1,153 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { CodeBlock } from '@/components/CodeBlock'; +import { ReactAriaLink } from '@/components/ReactAriaLink'; +import { + Preview, + WithLabelAndDescription, + Sizes, + WithIcon, + Disabled, + DisabledOption, + AllowsCustomValue, + WithSections, +} from './components'; +import { comboboxPropDefs } from './props-definition'; +import { + optionPropDefs, + optionSectionPropDefs, +} from '../select/props-definition'; +import { + comboboxUsageSnippet, + comboboxDefaultSnippet, + comboboxDescriptionSnippet, + comboboxSizesSnippet, + comboboxDisabledSnippet, + comboboxResponsiveSnippet, + comboboxIconSnippet, + comboboxDisabledOptionsSnippet, + comboboxCustomValueSnippet, + comboboxSectionsSnippet, +} from './snippets'; +import { PageTitle } from '@/components/PageTitle'; +import { Theming } from '@/components/Theming'; +import { ChangelogComponent } from '@/components/ChangelogComponent'; +import { ComboboxDefinition } from '../../../utils/definitions'; + +export const reactAriaUrls = { + combobox: 'https://react-aria.adobe.com/ComboBox', +}; + + + +} + code={comboboxDefaultSnippet} +/> + +## Usage + + + +## API reference + + + + + +### Option types + +The `options` prop accepts an array containing either of the following shapes. + +#### `Option` + + + +#### `OptionSection` + + + +## Examples + +### Label and description + +} + code={comboboxDescriptionSnippet} +/> + +### Sizes + +} + code={comboboxSizesSnippet} +/> + +### With icon + +} + code={comboboxIconSnippet} +/> + +### Disabled + +} + code={comboboxDisabledSnippet} +/> + +### Disabled options + +} + code={comboboxDisabledOptionsSnippet} +/> + +### Custom values + +Allow the user to type a value that is not in the option list by setting `allowsCustomValue`. + +} + code={comboboxCustomValueSnippet} +/> + +### With sections + +Group options under section headings by passing objects with a `title` and a +nested `options` array. + +} + code={comboboxSectionsSnippet} +/> + +### Responsive + +Size can change at different breakpoints. + + + + + + diff --git a/docs-ui/src/app/components/combobox/props-definition.tsx b/docs-ui/src/app/components/combobox/props-definition.tsx new file mode 100644 index 0000000000..08499cc40b --- /dev/null +++ b/docs-ui/src/app/components/combobox/props-definition.tsx @@ -0,0 +1,113 @@ +import { + classNamePropDefs, + stylePropDefs, + type PropDef, +} from '@/utils/propDefs'; +import { Chip } from '@/components/Chip'; + +export const comboboxPropDefs: Record = { + options: { + type: 'enum', + values: ['(Option | OptionSection)[]'], + description: ( + <> + Options to display in the dropdown. Pass Option objects + directly, or OptionSection objects to render grouped + options under section headings. + + ), + }, + allowsCustomValue: { + type: 'boolean', + default: 'false', + description: + 'When true, the typed text is accepted as the value on blur or Enter even if it does not match any option.', + }, + value: { + type: 'string', + description: 'Controlled selected value.', + }, + defaultValue: { + type: 'string', + description: 'Initial value for uncontrolled usage.', + }, + onChange: { + type: 'enum', + values: ['(value: Key | null) => void'], + description: 'Called when the selected option changes.', + }, + inputValue: { + type: 'string', + description: 'Controlled input text.', + }, + defaultInputValue: { + type: 'string', + description: 'Initial input text for uncontrolled usage.', + }, + onInputChange: { + type: 'enum', + values: ['(value: string) => void'], + description: 'Called when the input text changes.', + }, + label: { + type: 'string', + description: 'Visible label above the combobox.', + }, + secondaryLabel: { + type: 'string', + description: ( + <> + Secondary text shown next to the label. If not provided and isRequired + is true, displays Required. + + ), + }, + description: { + type: 'string', + description: 'Helper text displayed below the label.', + }, + placeholder: { + type: 'string', + description: 'Text shown when the input is empty.', + }, + size: { + type: 'enum', + values: ['small', 'medium'], + default: 'small', + responsive: true, + description: 'Visual size of the combobox field.', + }, + icon: { + type: 'enum', + values: ['ReactNode'], + description: 'Icon displayed before the input.', + }, + onOpenChange: { + type: 'enum', + values: ['(isOpen: boolean) => void'], + description: 'Called when the dropdown opens or closes.', + }, + isDisabled: { + type: 'boolean', + description: 'Prevents user interaction when true.', + }, + disabledKeys: { + type: 'enum', + values: ['Iterable'], + description: 'Keys of options that should be disabled.', + }, + isRequired: { + type: 'boolean', + description: 'Marks the field as required for form validation.', + }, + isInvalid: { + type: 'boolean', + description: 'Displays the combobox in an error state.', + }, + name: { + type: 'string', + description: 'Form field name for form submission.', + }, + ...classNamePropDefs, + ...stylePropDefs, +}; diff --git a/docs-ui/src/app/components/combobox/snippets.ts b/docs-ui/src/app/components/combobox/snippets.ts new file mode 100644 index 0000000000..28ca8c90af --- /dev/null +++ b/docs-ui/src/app/components/combobox/snippets.ts @@ -0,0 +1,114 @@ +export const comboboxUsageSnippet = `import { Combobox } from '@backstage/ui'; + +`; + +export const comboboxDefaultSnippet = ``; + +export const comboboxDescriptionSnippet = ``; + +export const comboboxIconSnippet = `} + options={[ ... ]} +/>`; + +export const comboboxSizesSnippet = ` + + +`; + +export const comboboxDisabledSnippet = ``; + +export const comboboxResponsiveSnippet = ``; + +export const comboboxDisabledOptionsSnippet = ``; + +export const comboboxCustomValueSnippet = ``; + +export const comboboxSectionsSnippet = ``; diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index 55f10eba95..fb7ac148a5 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -49,6 +49,11 @@ export const components: Page[] = [ title: 'CheckboxGroup', slug: 'checkbox-group', }, + { + title: 'Combobox', + slug: 'combobox', + status: 'new', + }, { title: 'Container', slug: 'container', diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 7b5d9806fd..3a51b61b38 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -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 +>; + +// @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>; + options?: Array; + placeholder?: string; + label?: FieldLabelProps['label']; + secondaryLabel?: FieldLabelProps['secondaryLabel']; + description?: FieldLabelProps['description']; + isRequired?: boolean; + className?: string; +}; + +// @public (undocumented) +export interface ComboboxProps + extends ComboboxOwnProps, + Omit, 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) diff --git a/packages/ui/src/components/Combobox/Combobox.module.css b/packages/ui/src/components/Combobox/Combobox.module.css new file mode 100644 index 0000000000..3ec2748cec --- /dev/null +++ b/packages/ui/src/components/Combobox/Combobox.module.css @@ -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); + } +} diff --git a/packages/ui/src/components/Combobox/Combobox.stories.tsx b/packages/ui/src/components/Combobox/Combobox.stories.tsx new file mode 100644 index 0000000000..6cd1aa93de --- /dev/null +++ b/packages/ui/src/components/Combobox/Combobox.stories.tsx @@ -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 => } />, +}); + +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 => ( + + } /> + } /> + + ), +}); + +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 => ( +
+ + + ), +}); + +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 }) => ( +
+ +
+ ), + ], +}); + +export const AutoBg = meta.story({ + render: () => ( + +
+ Combobox automatically detects its parent bg context and increments the + neutral level by 1. No prop is needed — it's fully automatic. +
+ + Neutral 1 container + + + + + + + Neutral 2 container + + + + + + + + + Neutral 3 container + + + + + + +
+ ), +}); + +export const WithAccessibilityProps = meta.story({ + args: { + ...Default.input.args, + }, + render: args => ( + +
+

With aria-label

+ +
+
+

With aria-labelledby

+
+ Font Family Selection +
+ +
+
+ ), +}); diff --git a/packages/ui/src/components/Combobox/Combobox.tsx b/packages/ui/src/components/Combobox/Combobox.tsx new file mode 100644 index 0000000000..b59dffb320 --- /dev/null +++ b/packages/ui/src/components/Combobox/Combobox.tsx @@ -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( + (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 ( + + + + + + + + + ); + }, +); + +Combobox.displayName = 'Combobox'; diff --git a/packages/ui/src/components/Combobox/ComboboxInput.tsx b/packages/ui/src/components/Combobox/ComboboxInput.tsx new file mode 100644 index 0000000000..ce7acfff71 --- /dev/null +++ b/packages/ui/src/components/Combobox/ComboboxInput.tsx @@ -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 ( + + {icon ? ( + + ) : null} + + + + ); +} diff --git a/packages/ui/src/components/Combobox/ComboboxListBox.tsx b/packages/ui/src/components/Combobox/ComboboxListBox.tsx new file mode 100644 index 0000000000..ae65bd8da3 --- /dev/null +++ b/packages/ui/src/components/Combobox/ComboboxListBox.tsx @@ -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
No results found.
; +}; + +function ComboboxItem({ option }: { option: Option }) { + const { ownProps } = useDefinition(ComboboxListBoxItemDefinition, {}); + const { classes } = ownProps; + + return ( + +
+
+ + {option.label} + +
+ ); +} + +function ComboboxSectionItems({ section }: { section: OptionSection }) { + const { ownProps } = useDefinition(ComboboxSectionDefinition, {}); + const { classes } = ownProps; + + return ( + +
{section.title}
+ {section.options.map(option => ( + + ))} +
+ ); +} + +export function ComboboxListBox(props: ComboboxListBoxOwnProps) { + const { ownProps } = useDefinition(ComboboxListBoxDefinition, props); + const { classes, options } = ownProps; + + return ( + }> + {options?.map(item => + 'options' in item ? ( + + ) : ( + + ), + )} + + ); +} diff --git a/packages/ui/src/components/Combobox/definition.ts b/packages/ui/src/components/Combobox/definition.ts new file mode 100644 index 0000000000..06143e7c05 --- /dev/null +++ b/packages/ui/src/components/Combobox/definition.ts @@ -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()({ + 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()( + { + 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()({ + styles, + classNames: { + root: 'bui-ComboboxList', + noResults: 'bui-ComboboxNoResults', + }, + propDefs: { + options: {}, + }, + }); + +/** + * Component definition for ComboboxListBoxItem + * @public + */ +export const ComboboxListBoxItemDefinition = + defineComponent()({ + styles, + classNames: { + root: 'bui-ComboboxItem', + indicator: 'bui-ComboboxItemIndicator', + label: 'bui-ComboboxItemLabel', + }, + propDefs: {}, + }); + +/** + * Component definition for ComboboxSection + * @public + */ +export const ComboboxSectionDefinition = + defineComponent()({ + styles, + classNames: { + root: 'bui-ComboboxSection', + header: 'bui-ComboboxSectionHeader', + }, + propDefs: {}, + }); diff --git a/packages/ui/src/components/Combobox/index.ts b/packages/ui/src/components/Combobox/index.ts new file mode 100644 index 0000000000..9c9a52ff1a --- /dev/null +++ b/packages/ui/src/components/Combobox/index.ts @@ -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'; diff --git a/packages/ui/src/components/Combobox/types.ts b/packages/ui/src/components/Combobox/types.ts new file mode 100644 index 0000000000..6d2c0423e7 --- /dev/null +++ b/packages/ui/src/components/Combobox/types.ts @@ -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>; + + /** + * 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