From 04d9d8df40a7a47d7c72f7abc494b64b1edd2677 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sat, 14 Mar 2026 14:37:33 +0000 Subject: [PATCH] feat(ui): add ListBox and ListBoxItem components Adds standalone `ListBox` and `ListBoxItem` components to `@backstage/ui`, built on top of React Aria's ListBox primitives. Items support icons, descriptions, and single or multiple selection modes. Includes Storybook stories and docs-ui documentation page. Signed-off-by: Charles de Dreuille Made-with: Cursor --- .changeset/add-listbox-component.md | 5 + .../app/components/list-box/components.tsx | 150 +++++++++++++++ docs-ui/src/app/components/list-box/page.mdx | 85 +++++++++ .../components/list-box/props-definition.tsx | 72 +++++++ .../src/app/components/list-box/snippets.ts | 75 ++++++++ docs-ui/src/utils/data.ts | 4 + .../src/components/ListBox/ListBox.module.css | 106 +++++++++++ .../components/ListBox/ListBox.stories.tsx | 180 ++++++++++++++++++ .../ui/src/components/ListBox/ListBox.tsx | 79 ++++++++ .../ui/src/components/ListBox/definition.ts | 57 ++++++ packages/ui/src/components/ListBox/index.ts | 24 +++ packages/ui/src/components/ListBox/types.ts | 71 +++++++ packages/ui/src/definitions.ts | 4 + packages/ui/src/index.ts | 1 + 14 files changed, 913 insertions(+) create mode 100644 .changeset/add-listbox-component.md create mode 100644 docs-ui/src/app/components/list-box/components.tsx create mode 100644 docs-ui/src/app/components/list-box/page.mdx create mode 100644 docs-ui/src/app/components/list-box/props-definition.tsx create mode 100644 docs-ui/src/app/components/list-box/snippets.ts create mode 100644 packages/ui/src/components/ListBox/ListBox.module.css create mode 100644 packages/ui/src/components/ListBox/ListBox.stories.tsx create mode 100644 packages/ui/src/components/ListBox/ListBox.tsx create mode 100644 packages/ui/src/components/ListBox/definition.ts create mode 100644 packages/ui/src/components/ListBox/index.ts create mode 100644 packages/ui/src/components/ListBox/types.ts diff --git a/.changeset/add-listbox-component.md b/.changeset/add-listbox-component.md new file mode 100644 index 0000000000..45faab32c5 --- /dev/null +++ b/.changeset/add-listbox-component.md @@ -0,0 +1,5 @@ +--- +'@backstage/ui': patch +--- + +Added `ListBox` and `ListBoxItem` components. These provide a standalone, accessible list of selectable options built on top of React Aria's `ListBox` and `ListBoxItem` primitives. Items support icons, descriptions, and single or multiple selection modes. diff --git a/docs-ui/src/app/components/list-box/components.tsx b/docs-ui/src/app/components/list-box/components.tsx new file mode 100644 index 0000000000..9db1ea57f3 --- /dev/null +++ b/docs-ui/src/app/components/list-box/components.tsx @@ -0,0 +1,150 @@ +'use client'; + +import { + ListBox, + ListBoxItem, +} from '../../../../../packages/ui/src/components/ListBox/ListBox'; +import { useState } from 'react'; +import type { Selection } from 'react-aria-components'; +import { + RiJavascriptLine, + RiReactjsLine, + RiRustLine, + RiTerminalLine, + RiCodeLine, +} from '@remixicon/react'; + +const items = [ + { id: 'react', label: 'React' }, + { id: 'typescript', label: 'TypeScript' }, + { id: 'javascript', label: 'JavaScript' }, + { id: 'rust', label: 'Rust' }, + { id: 'go', label: 'Go' }, +]; + +const itemsWithDescription = [ + { + id: 'react', + label: 'React', + description: 'A JavaScript library for building user interfaces', + }, + { + id: 'typescript', + label: 'TypeScript', + description: 'Typed superset of JavaScript', + }, + { + id: 'javascript', + label: 'JavaScript', + description: 'The language of the web', + }, + { + id: 'rust', + label: 'Rust', + description: 'Systems programming with memory safety', + }, + { + id: 'go', + label: 'Go', + description: 'Simple, fast, and reliable', + }, +]; + +const itemIcons: Record = { + react: , + typescript: , + javascript: , + rust: , + go: , +}; + +export const Default = () => ( + + {items.map(item => ( + + {item.label} + + ))} + +); + +export const WithIcons = () => ( + + {items.map(item => ( + + {item.label} + + ))} + +); + +export const WithDescription = () => ( + + {itemsWithDescription.map(item => ( + + {item.label} + + ))} + +); + +export const SelectionModeSingle = () => { + const [selected, setSelected] = useState(new Set(['react'])); + + return ( + + {items.map(item => ( + + {item.label} + + ))} + + ); +}; + +export const SelectionModeMultiple = () => { + const [selected, setSelected] = useState( + new Set(['react', 'typescript']), + ); + + return ( + + {items.map(item => ( + + {item.label} + + ))} + + ); +}; + +export const Disabled = () => ( + + {items.map(item => ( + + {item.label} + + ))} + +); diff --git a/docs-ui/src/app/components/list-box/page.mdx b/docs-ui/src/app/components/list-box/page.mdx new file mode 100644 index 0000000000..e0340dbc38 --- /dev/null +++ b/docs-ui/src/app/components/list-box/page.mdx @@ -0,0 +1,85 @@ +import { PropsTable } from '@/components/PropsTable'; +import { Snippet } from '@/components/Snippet'; +import { CodeBlock } from '@/components/CodeBlock'; +import { ReactAriaLink } from '@/components/ReactAriaLink'; +import { + Default, + WithIcons, + WithDescription, + SelectionModeSingle, + SelectionModeMultiple, + Disabled, +} from './components'; +import { listBoxPropDefs, listBoxItemPropDefs } from './props-definition'; +import { + usage, + preview, + withIcons, + withDescription, + selectionModeSingle, + selectionModeMultiple, + disabled, +} from './snippets'; +import { PageTitle } from '@/components/PageTitle'; +import { Theming } from '@/components/Theming'; +import { ListBoxDefinition, ListBoxItemDefinition } from '../../../utils/definitions'; +import { ChangelogComponent } from '@/components/ChangelogComponent'; + +export const reactAriaUrls = { + listBox: 'https://react-aria.adobe.com/ListBox', +}; + + + +} code={preview} /> + +## Usage + + + +## API reference + +### ListBox + +Container for a list of selectable options. + + + + + +### ListBoxItem + +Individual item within a ListBox. + + + + + +## Examples + +### With icons + +} code={withIcons} /> + +### With description + +} code={withDescription} /> + +### Single selection + +} code={selectionModeSingle} /> + +### Multiple selection + +} code={selectionModeMultiple} /> + +### Disabled items + +} code={disabled} /> + + + + diff --git a/docs-ui/src/app/components/list-box/props-definition.tsx b/docs-ui/src/app/components/list-box/props-definition.tsx new file mode 100644 index 0000000000..861b211bc0 --- /dev/null +++ b/docs-ui/src/app/components/list-box/props-definition.tsx @@ -0,0 +1,72 @@ +import { + classNamePropDefs, + childrenPropDefs, + type PropDef, +} from '@/utils/propDefs'; + +export const listBoxPropDefs: Record = { + items: { + type: 'enum', + values: ['Iterable'], + description: 'Item objects in the collection.', + }, + renderEmptyState: { + type: 'enum', + values: ['() => ReactNode'], + description: 'Content to display when the collection is empty.', + }, + selectionMode: { + type: 'enum', + values: ['none', 'single', 'multiple'], + description: 'The type of selection allowed.', + }, + selectedKeys: { + type: 'enum', + values: ['all', 'Iterable'], + description: 'The currently selected keys (controlled).', + }, + defaultSelectedKeys: { + type: 'enum', + values: ['all', 'Iterable'], + description: 'The initial selected keys (uncontrolled).', + }, + disabledKeys: { + type: 'enum', + values: ['Iterable'], + description: 'Keys of items that should be disabled.', + }, + onSelectionChange: { + type: 'enum', + values: ['(keys: Selection) => void'], + description: 'Handler called when the selection changes.', + }, + ...childrenPropDefs, + ...classNamePropDefs, +}; + +export const listBoxItemPropDefs: Record = { + id: { + type: 'string', + description: 'Unique identifier for the item.', + }, + textValue: { + type: 'string', + description: + 'Text value for accessibility. Derived from children if string.', + }, + icon: { + type: 'enum', + values: ['ReactNode'], + description: 'Icon displayed before the item label.', + }, + description: { + type: 'string', + description: 'Secondary description text displayed below the label.', + }, + isDisabled: { + type: 'boolean', + description: 'Whether the item is disabled.', + }, + ...childrenPropDefs, + ...classNamePropDefs, +}; diff --git a/docs-ui/src/app/components/list-box/snippets.ts b/docs-ui/src/app/components/list-box/snippets.ts new file mode 100644 index 0000000000..cd405a3154 --- /dev/null +++ b/docs-ui/src/app/components/list-box/snippets.ts @@ -0,0 +1,75 @@ +export const usage = `import { ListBox, ListBoxItem } from '@backstage/ui'; + + + React + TypeScript + JavaScript +`; + +export const preview = ` + React + TypeScript + JavaScript + Rust + Go +`; + +export const withIcons = ` + }>React + }>TypeScript + }>JavaScript +`; + +export const withDescription = ` + } + description="A JavaScript library for building user interfaces" + > + React + + } + description="Typed superset of JavaScript" + > + TypeScript + +`; + +export const selectionModeSingle = `const [selected, setSelected] = useState(new Set(['react'])); + + + React + TypeScript + JavaScript +`; + +export const selectionModeMultiple = `const [selected, setSelected] = useState(new Set(['react', 'typescript'])); + + + React + TypeScript + JavaScript +`; + +export const disabled = ` + React + TypeScript + JavaScript + Rust + Go +`; diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index 753473ab64..5ade525e9b 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -69,6 +69,10 @@ export const components: Page[] = [ title: 'Link', slug: 'link', }, + { + title: 'ListBox', + slug: 'list-box', + }, { title: 'Menu', slug: 'menu', diff --git a/packages/ui/src/components/ListBox/ListBox.module.css b/packages/ui/src/components/ListBox/ListBox.module.css new file mode 100644 index 0000000000..7b26605a8c --- /dev/null +++ b/packages/ui/src/components/ListBox/ListBox.module.css @@ -0,0 +1,106 @@ +/* + * 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. + */ + +@layer tokens, base, components, utilities; + +@layer components { + .bui-ListBox { + box-sizing: border-box; + overflow-y: auto; + outline: none; + + &:focus-visible { + outline: none; + } + } + + .bui-ListBoxItem { + box-sizing: border-box; + display: flex; + align-items: center; + gap: var(--bui-space-2); + padding-block: var(--bui-space-2); + padding-inline: var(--bui-space-3); + border-radius: var(--bui-radius-2); + font-size: var(--bui-font-size-3); + font-family: var(--bui-font-regular); + color: var(--bui-fg-primary); + cursor: pointer; + user-select: none; + outline: none; + + &[data-hovered] { + background-color: var(--bui-bg-neutral-2); + } + + &[data-focus-visible] { + background-color: var(--bui-bg-neutral-2); + } + + &[data-selected] { + .bui-ListBoxItemCheck { + opacity: 1; + } + } + + &[data-disabled] { + cursor: not-allowed; + color: var(--bui-fg-disabled); + } + } + + .bui-ListBoxItemCheck { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + opacity: 0; + transition: opacity 0.2s ease-in-out; + width: 1rem; + height: 1rem; + + & svg { + width: 1rem; + height: 1rem; + } + } + + .bui-ListBoxItemIcon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + color: var(--bui-fg-secondary); + + & svg { + width: 1rem; + height: 1rem; + } + } + + .bui-ListBoxItemLabel { + flex: 1; + display: flex; + flex-direction: column; + gap: var(--bui-space-1); + min-width: 0; + } + + .bui-ListBoxItemDescription { + font-size: var(--bui-font-size-2); + color: var(--bui-fg-secondary); + } +} diff --git a/packages/ui/src/components/ListBox/ListBox.stories.tsx b/packages/ui/src/components/ListBox/ListBox.stories.tsx new file mode 100644 index 0000000000..0e3e50e006 --- /dev/null +++ b/packages/ui/src/components/ListBox/ListBox.stories.tsx @@ -0,0 +1,180 @@ +/* + * 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 preview from '../../../../../.storybook/preview'; +import { useState } from 'react'; +import { ListBox, ListBoxItem } from './ListBox'; +import type { Selection } from 'react-aria-components'; +import { + RiJavascriptLine, + RiReactjsLine, + RiShipLine, + RiTerminalLine, + RiCodeLine, +} from '@remixicon/react'; + +const meta = preview.meta({ + title: 'Backstage UI/ListBox', + component: ListBox, + args: { + style: { width: 280 }, + 'aria-label': 'List', + }, +}); + +const items = [ + { id: 'react', label: 'React' }, + { id: 'typescript', label: 'TypeScript' }, + { id: 'javascript', label: 'JavaScript' }, + { id: 'rust', label: 'Rust' }, + { id: 'go', label: 'Go' }, +]; + +const itemsWithDescription = [ + { + id: 'react', + label: 'React', + description: 'A JavaScript library for building user interfaces', + }, + { + id: 'typescript', + label: 'TypeScript', + description: 'Typed superset of JavaScript', + }, + { + id: 'javascript', + label: 'JavaScript', + description: 'The language of the web', + }, + { + id: 'rust', + label: 'Rust', + description: 'Systems programming with memory safety', + }, + { + id: 'go', + label: 'Go', + description: 'Simple, fast, and reliable', + }, +]; + +const itemIcons: Record = { + react: , + typescript: , + javascript: , + rust: , + go: , +}; + +export const Default = meta.story({ + render: args => ( + + {items.map(item => ( + + {item.label} + + ))} + + ), +}); + +export const WithIcons = meta.story({ + render: args => ( + + {items.map(item => ( + + {item.label} + + ))} + + ), +}); + +export const WithDescription = meta.story({ + args: { + style: { width: 340 }, + }, + render: args => ( + + {itemsWithDescription.map(item => ( + + {item.label} + + ))} + + ), +}); + +export const SelectionModeSingle = meta.story({ + render: args => { + const [selected, setSelected] = useState(new Set(['react'])); + + return ( + + {items.map(item => ( + + {item.label} + + ))} + + ); + }, +}); + +export const SelectionModeMultiple = meta.story({ + render: args => { + const [selected, setSelected] = useState( + new Set(['react', 'typescript']), + ); + + return ( + + {items.map(item => ( + + {item.label} + + ))} + + ); + }, +}); + +export const Disabled = meta.story({ + render: args => ( + + {items.map(item => ( + + {item.label} + + ))} + + ), +}); diff --git a/packages/ui/src/components/ListBox/ListBox.tsx b/packages/ui/src/components/ListBox/ListBox.tsx new file mode 100644 index 0000000000..91ffbae50a --- /dev/null +++ b/packages/ui/src/components/ListBox/ListBox.tsx @@ -0,0 +1,79 @@ +/* + * 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 as RAListBox, + ListBoxItem as RAListBoxItem, + Text, +} from 'react-aria-components'; +import { RiCheckLine } from '@remixicon/react'; +import { useDefinition } from '../../hooks/useDefinition'; +import { ListBoxDefinition, ListBoxItemDefinition } from './definition'; +import type { ListBoxProps, ListBoxItemProps } from './types'; + +/** + * A listbox displays a list of options and allows a user to select one or more of them. + * + * @public + */ +export const ListBox = (props: ListBoxProps) => { + const { ownProps, restProps } = useDefinition(ListBoxDefinition, props); + const { classes, items, children, renderEmptyState } = ownProps; + + return ( + + {children} + + ); +}; + +/** + * An item within a ListBox. + * + * @public + */ +export const ListBoxItem = (props: ListBoxItemProps) => { + const { ownProps, restProps } = useDefinition(ListBoxItemDefinition, props); + const { classes, children, description, icon } = ownProps; + + const textValue = typeof children === 'string' ? children : undefined; + + return ( + +
+ +
+ {icon && {icon}} +
+ {children} + {description && ( + + {description} + + )} +
+
+ ); +}; diff --git a/packages/ui/src/components/ListBox/definition.ts b/packages/ui/src/components/ListBox/definition.ts new file mode 100644 index 0000000000..6c2b41d883 --- /dev/null +++ b/packages/ui/src/components/ListBox/definition.ts @@ -0,0 +1,57 @@ +/* + * 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 { defineComponent } from '../../hooks/useDefinition'; +import type { ListBoxOwnProps, ListBoxItemOwnProps } from './types'; +import styles from './ListBox.module.css'; + +/** + * Component definition for ListBox + * @public + */ +export const ListBoxDefinition = defineComponent()({ + styles, + classNames: { + root: 'bui-ListBox', + }, + propDefs: { + items: {}, + children: {}, + renderEmptyState: {}, + className: {}, + }, +}); + +/** + * Component definition for ListBoxItem + * @public + */ +export const ListBoxItemDefinition = defineComponent()({ + styles, + classNames: { + root: 'bui-ListBoxItem', + check: 'bui-ListBoxItemCheck', + icon: 'bui-ListBoxItemIcon', + label: 'bui-ListBoxItemLabel', + description: 'bui-ListBoxItemDescription', + }, + propDefs: { + children: {}, + description: {}, + icon: {}, + className: {}, + }, +}); diff --git a/packages/ui/src/components/ListBox/index.ts b/packages/ui/src/components/ListBox/index.ts new file mode 100644 index 0000000000..fc3d35facd --- /dev/null +++ b/packages/ui/src/components/ListBox/index.ts @@ -0,0 +1,24 @@ +/* + * 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. + */ + +export { ListBox, ListBoxItem } from './ListBox'; +export type { + ListBoxProps, + ListBoxOwnProps, + ListBoxItemProps, + ListBoxItemOwnProps, +} from './types'; +export { ListBoxDefinition, ListBoxItemDefinition } from './definition'; diff --git a/packages/ui/src/components/ListBox/types.ts b/packages/ui/src/components/ListBox/types.ts new file mode 100644 index 0000000000..cf936babf7 --- /dev/null +++ b/packages/ui/src/components/ListBox/types.ts @@ -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 type { + ListBoxProps as ReactAriaListBoxProps, + ListBoxItemProps as ReactAriaListBoxItemProps, +} from 'react-aria-components'; + +/** + * Own props for the ListBox component. + * + * @public + */ +export type ListBoxOwnProps = { + items?: ReactAriaListBoxProps['items']; + children?: ReactAriaListBoxProps['children']; + renderEmptyState?: ReactAriaListBoxProps['renderEmptyState']; + className?: string; +}; + +/** + * Props for the ListBox component. + * + * @public + */ +export interface ListBoxProps + extends ListBoxOwnProps, + Omit, keyof ListBoxOwnProps> {} + +/** + * Own props for the ListBoxItem component. + * + * @public + */ +export type ListBoxItemOwnProps = { + /** + * The main label content of the item. + */ + children?: React.ReactNode; + /** + * Optional secondary description text. + */ + description?: string; + /** + * Optional icon displayed before the label. + */ + icon?: React.ReactNode; + className?: string; +}; + +/** + * Props for the ListBoxItem component. + * + * @public + */ +export interface ListBoxItemProps + extends ListBoxItemOwnProps, + Omit {} diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index a07fff7462..1d4d82ff41 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -48,6 +48,10 @@ export { HeaderPageDefinition, } from './components/Header/definition'; export { LinkDefinition } from './components/Link/definition'; +export { + ListBoxDefinition, + ListBoxItemDefinition, +} from './components/ListBox/definition'; export { MenuDefinition } from './components/Menu/definition'; export { PasswordFieldDefinition } from './components/PasswordField/definition'; export { PopoverDefinition } from './components/Popover/definition'; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 290ae2a80f..3996fe4083 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -53,6 +53,7 @@ export * from './components/Menu'; export * from './components/Popover'; export * from './components/SearchField'; export * from './components/Link'; +export * from './components/ListBox'; export * from './components/Select'; export * from './components/Skeleton'; export * from './components/Switch';