From 04d9d8df40a7a47d7c72f7abc494b64b1edd2677 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sat, 14 Mar 2026 14:37:33 +0000 Subject: [PATCH 01/14] 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'; From b7b1b86d8abb241d224c061307e23d6c89f625bb Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sat, 14 Mar 2026 18:11:39 +0000 Subject: [PATCH 02/14] fix(ui): update ListBox item check and icon design - Check icon is now only rendered when the item is selected, so it takes no space when unselected and pushes content in when selected - Icon slot is now a fixed 32x32px box to consistently frame any React icon component (e.g. from Remix icons) Signed-off-by: Charles de Dreuille Made-with: Cursor --- .../src/components/ListBox/ListBox.module.css | 10 ++----- .../ui/src/components/ListBox/ListBox.tsx | 30 +++++++++++-------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/ui/src/components/ListBox/ListBox.module.css b/packages/ui/src/components/ListBox/ListBox.module.css index 7b26605a8c..4862aa6607 100644 --- a/packages/ui/src/components/ListBox/ListBox.module.css +++ b/packages/ui/src/components/ListBox/ListBox.module.css @@ -50,12 +50,6 @@ background-color: var(--bui-bg-neutral-2); } - &[data-selected] { - .bui-ListBoxItemCheck { - opacity: 1; - } - } - &[data-disabled] { cursor: not-allowed; color: var(--bui-fg-disabled); @@ -67,8 +61,6 @@ align-items: center; justify-content: center; flex-shrink: 0; - opacity: 0; - transition: opacity 0.2s ease-in-out; width: 1rem; height: 1rem; @@ -83,6 +75,8 @@ align-items: center; justify-content: center; flex-shrink: 0; + width: 2rem; + height: 2rem; color: var(--bui-fg-secondary); & svg { diff --git a/packages/ui/src/components/ListBox/ListBox.tsx b/packages/ui/src/components/ListBox/ListBox.tsx index 91ffbae50a..51d7132dce 100644 --- a/packages/ui/src/components/ListBox/ListBox.tsx +++ b/packages/ui/src/components/ListBox/ListBox.tsx @@ -62,18 +62,24 @@ export const ListBoxItem = (props: ListBoxItemProps) => { className={classes.root} {...restProps} > -
- -
- {icon && {icon}} -
- {children} - {description && ( - - {description} - - )} -
+ {({ isSelected }) => ( + <> + {isSelected && ( +
+ +
+ )} + {icon &&
{icon}
} +
+ {children} + {description && ( + + {description} + + )} +
+ + )} ); }; From 2b1bb9ca3e9a62a10527d2ed8b2971017485b512 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sat, 14 Mar 2026 18:17:03 +0000 Subject: [PATCH 03/14] feat(ui): add customActions prop to ListBoxItem Adds a `customActions` prop that renders a flex row of React nodes on the right side of a list item. Click and keyboard events on the actions area are stopped from propagating to the item's selection handler. Adds two new stories: - WithActionsMenu: ButtonIcon (ellipsis) triggering a dropdown Menu - WithActionsTags: inline TagGroup showing metadata tags per item Signed-off-by: Charles de Dreuille Made-with: Cursor --- .../src/components/ListBox/ListBox.module.css | 9 ++ .../components/ListBox/ListBox.stories.tsx | 85 ++++++++++++++++++- .../ui/src/components/ListBox/ListBox.tsx | 18 +++- .../ui/src/components/ListBox/definition.ts | 2 + packages/ui/src/components/ListBox/types.ts | 7 +- 5 files changed, 117 insertions(+), 4 deletions(-) diff --git a/packages/ui/src/components/ListBox/ListBox.module.css b/packages/ui/src/components/ListBox/ListBox.module.css index 4862aa6607..99fea1adc3 100644 --- a/packages/ui/src/components/ListBox/ListBox.module.css +++ b/packages/ui/src/components/ListBox/ListBox.module.css @@ -78,6 +78,7 @@ width: 2rem; height: 2rem; color: var(--bui-fg-secondary); + border-radius: var(--bui-radius-2); & svg { width: 1rem; @@ -97,4 +98,12 @@ font-size: var(--bui-font-size-2); color: var(--bui-fg-secondary); } + + .bui-ListBoxItemActions { + display: flex; + align-items: center; + gap: var(--bui-space-1); + flex-shrink: 0; + margin-left: auto; + } } diff --git a/packages/ui/src/components/ListBox/ListBox.stories.tsx b/packages/ui/src/components/ListBox/ListBox.stories.tsx index 0e3e50e006..26c8465494 100644 --- a/packages/ui/src/components/ListBox/ListBox.stories.tsx +++ b/packages/ui/src/components/ListBox/ListBox.stories.tsx @@ -17,22 +17,37 @@ import preview from '../../../../../.storybook/preview'; import { useState } from 'react'; import { ListBox, ListBoxItem } from './ListBox'; +import { ButtonIcon } from '../ButtonIcon'; +import { MenuTrigger, Menu, MenuItem } from '../Menu'; +import { TagGroup, Tag } from '../TagGroup'; import type { Selection } from 'react-aria-components'; import { RiJavascriptLine, + RiMoreLine, RiReactjsLine, RiShipLine, RiTerminalLine, RiCodeLine, + RiDeleteBinLine, + RiEdit2Line, + RiShareBoxLine, } from '@remixicon/react'; +import { MemoryRouter } from 'react-router-dom'; const meta = preview.meta({ title: 'Backstage UI/ListBox', component: ListBox, args: { - style: { width: 280 }, + style: { width: 320 }, 'aria-label': 'List', }, + decorators: [ + Story => ( + + + + ), + ], }); const items = [ @@ -178,3 +193,71 @@ export const Disabled = meta.story({ ), }); + +export const WithActionsMenu = meta.story({ + render: args => ( + + {items.map(item => ( + + } + size="small" + aria-label="More actions" + /> + + }>Edit + }>Share + } color="danger"> + Delete + + + + } + > + {item.label} + + ))} + + ), +}); + +export const WithActionsTags = meta.story({ + args: { + style: { width: 380 }, + }, + render: args => { + const tagMap: Record = { + react: ['frontend', 'ui'], + typescript: ['typed', 'js'], + javascript: ['web'], + rust: ['systems', 'fast'], + go: ['backend'], + }; + + return ( + + {items.map(item => ( + + {tagMap[item.id].map(tag => ( + {tag} + ))} + + } + > + {item.label} + + ))} + + ); + }, +}); diff --git a/packages/ui/src/components/ListBox/ListBox.tsx b/packages/ui/src/components/ListBox/ListBox.tsx index 51d7132dce..221dd5fdb9 100644 --- a/packages/ui/src/components/ListBox/ListBox.tsx +++ b/packages/ui/src/components/ListBox/ListBox.tsx @@ -23,6 +23,7 @@ import { RiCheckLine } from '@remixicon/react'; import { useDefinition } from '../../hooks/useDefinition'; import { ListBoxDefinition, ListBoxItemDefinition } from './definition'; import type { ListBoxProps, ListBoxItemProps } from './types'; +import { Box } from '../Box/Box'; /** * A listbox displays a list of options and allows a user to select one or more of them. @@ -52,7 +53,7 @@ export const ListBox = (props: ListBoxProps) => { */ export const ListBoxItem = (props: ListBoxItemProps) => { const { ownProps, restProps } = useDefinition(ListBoxItemDefinition, props); - const { classes, children, description, icon } = ownProps; + const { classes, children, description, icon, customActions } = ownProps; const textValue = typeof children === 'string' ? children : undefined; @@ -69,7 +70,11 @@ export const ListBoxItem = (props: ListBoxItemProps) => { )} - {icon &&
{icon}
} + {icon && ( + + {icon} + + )}
{children} {description && ( @@ -78,6 +83,15 @@ export const ListBoxItem = (props: ListBoxItemProps) => { )}
+ {customActions && ( +
e.stopPropagation()} + onKeyDown={e => e.stopPropagation()} + > + {customActions} +
+ )} )} diff --git a/packages/ui/src/components/ListBox/definition.ts b/packages/ui/src/components/ListBox/definition.ts index 6c2b41d883..ef8d6e4b61 100644 --- a/packages/ui/src/components/ListBox/definition.ts +++ b/packages/ui/src/components/ListBox/definition.ts @@ -47,11 +47,13 @@ export const ListBoxItemDefinition = defineComponent()({ icon: 'bui-ListBoxItemIcon', label: 'bui-ListBoxItemLabel', description: 'bui-ListBoxItemDescription', + actions: 'bui-ListBoxItemActions', }, propDefs: { children: {}, description: {}, icon: {}, + customActions: {}, className: {}, }, }); diff --git a/packages/ui/src/components/ListBox/types.ts b/packages/ui/src/components/ListBox/types.ts index cf936babf7..6299518467 100644 --- a/packages/ui/src/components/ListBox/types.ts +++ b/packages/ui/src/components/ListBox/types.ts @@ -55,9 +55,14 @@ export type ListBoxItemOwnProps = { */ description?: string; /** - * Optional icon displayed before the label. + * Optional icon displayed before the label, rendered in a 32×32px box. */ icon?: React.ReactNode; + /** + * Optional actions rendered in a flex row on the right side of the item, + * e.g. a ButtonIcon with a dropdown menu, or a set of tags. + */ + customActions?: React.ReactNode; className?: string; }; From 9ee2931cc8c54361796b3be4eda7001a6ea0d34d Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 15 Mar 2026 08:32:41 +0000 Subject: [PATCH 04/14] Improve styles Signed-off-by: Charles de Dreuille --- .storybook/preview.tsx | 1 + .../src/components/ListBox/ListBox.module.css | 109 +++++++- .../components/ListBox/ListBox.stories.tsx | 121 +++++++-- .../ui/src/components/ListBox/ListBox.tsx | 36 ++- .../ui/src/components/ListBox/definition.ts | 2 + packages/ui/src/components/ListBox/types.ts | 8 +- .../src/recipes/CardsWithListBox.stories.tsx | 240 ++++++++++++++++++ 7 files changed, 480 insertions(+), 37 deletions(-) create mode 100644 packages/ui/src/recipes/CardsWithListBox.stories.tsx diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index 9734cee295..022b6123e2 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -88,6 +88,7 @@ export default definePreview({ storySort: { order: [ 'Backstage UI', + 'Recipes', 'Guidelines', 'Plugins', 'Layout', diff --git a/packages/ui/src/components/ListBox/ListBox.module.css b/packages/ui/src/components/ListBox/ListBox.module.css index 99fea1adc3..ea323b9471 100644 --- a/packages/ui/src/components/ListBox/ListBox.module.css +++ b/packages/ui/src/components/ListBox/ListBox.module.css @@ -21,6 +21,14 @@ box-sizing: border-box; overflow-y: auto; outline: none; + display: flex; + flex-direction: column; + + gap: var(--bui-space-3); + + &:has([data-selection-mode]) { + gap: 0; + } &:focus-visible { outline: none; @@ -31,29 +39,94 @@ 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); + gap: 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-disabled] { cursor: not-allowed; color: var(--bui-fg-disabled); } + + &[data-selection-mode] { + cursor: pointer; + padding-block: var(--bui-space-2); + padding-inline: var(--bui-space-2); + + &[data-selected] { + &:has(+ [data-selected]) { + border-end-start-radius: 0; + border-end-end-radius: 0; + } + + + [data-selected] { + border-start-start-radius: 0; + border-start-end-radius: 0; + } + } + + &[data-hovered], + &[data-focus-visible] { + background-color: var(--bui-bg-neutral-1-hover); + } + + &[data-pressed], + &[data-selected], + &[data-selected][data-hovered], + &[data-selected][data-focus-visible], + &[data-selected][data-pressed] { + background-color: var(--bui-bg-neutral-1-pressed); + } + + &[data-on-bg='neutral-1'] { + &[data-hovered], + &[data-focus-visible] { + background-color: var(--bui-bg-neutral-2-hover); + } + + &[data-pressed], + &[data-selected], + &[data-selected][data-hovered], + &[data-selected][data-focus-visible], + &[data-selected][data-pressed] { + background-color: var(--bui-bg-neutral-2-pressed); + } + } + + &[data-on-bg='neutral-2'] { + &[data-hovered], + &[data-focus-visible] { + background-color: var(--bui-bg-neutral-3-hover); + } + + &[data-pressed], + &[data-selected], + &[data-selected][data-hovered], + &[data-selected][data-focus-visible], + &[data-selected][data-pressed] { + background-color: var(--bui-bg-neutral-3-pressed); + } + } + + &[data-on-bg='neutral-3'], + &[data-on-bg='neutral-4'] { + &[data-hovered], + &[data-focus-visible] { + background-color: var(--bui-bg-neutral-4-hover); + } + + &[data-pressed], + &[data-selected], + &[data-selected][data-hovered], + &[data-selected][data-focus-visible], + &[data-selected][data-pressed] { + background-color: var(--bui-bg-neutral-4-pressed); + } + } + } } .bui-ListBoxItemCheck { @@ -92,11 +165,21 @@ flex-direction: column; gap: var(--bui-space-1); min-width: 0; + overflow: hidden; + + & > * { + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; + } } .bui-ListBoxItemDescription { font-size: var(--bui-font-size-2); color: var(--bui-fg-secondary); + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; } .bui-ListBoxItemActions { diff --git a/packages/ui/src/components/ListBox/ListBox.stories.tsx b/packages/ui/src/components/ListBox/ListBox.stories.tsx index 26c8465494..2ad1429272 100644 --- a/packages/ui/src/components/ListBox/ListBox.stories.tsx +++ b/packages/ui/src/components/ListBox/ListBox.stories.tsx @@ -17,13 +17,11 @@ import preview from '../../../../../.storybook/preview'; import { useState } from 'react'; import { ListBox, ListBoxItem } from './ListBox'; -import { ButtonIcon } from '../ButtonIcon'; -import { MenuTrigger, Menu, MenuItem } from '../Menu'; +import { MenuItem } from '../Menu'; import { TagGroup, Tag } from '../TagGroup'; import type { Selection } from 'react-aria-components'; import { RiJavascriptLine, - RiMoreLine, RiReactjsLine, RiShipLine, RiTerminalLine, @@ -159,6 +157,27 @@ export const SelectionModeSingle = meta.story({ }, }); +export const SelectionModeSingleWithIcons = 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( @@ -182,6 +201,29 @@ export const SelectionModeMultiple = meta.story({ }, }); +export const SelectionModeMultipleWithIcons = 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 => ( @@ -195,6 +237,9 @@ export const Disabled = meta.story({ }); export const WithActionsMenu = meta.story({ + args: { + style: { width: 420 }, + }, render: args => ( {items.map(item => ( @@ -202,21 +247,14 @@ export const WithActionsMenu = meta.story({ key={item.id} id={item.id} icon={itemIcons[item.id]} - customActions={ - - } - size="small" - aria-label="More actions" - /> - - }>Edit - }>Share - } color="danger"> - Delete - - - + menuItems={ + <> + }>Edit + }>Share + } color="danger"> + Delete + + } > {item.label} @@ -228,7 +266,7 @@ export const WithActionsMenu = meta.story({ export const WithActionsTags = meta.story({ args: { - style: { width: 380 }, + style: { width: 420 }, }, render: args => { const tagMap: Record = { @@ -261,3 +299,48 @@ export const WithActionsTags = meta.story({ ); }, }); + +export const WithActionsMenuAndTags = meta.story({ + args: { + style: { width: 420 }, + }, + render: args => { + const tagMap: Record = { + react: ['frontend', 'ui'], + typescript: ['typed', 'js'], + javascript: ['web'], + rust: ['systems', 'fast'], + go: ['backend'], + }; + + return ( + + {items.map(item => ( + + }>Edit + }>Share + } color="danger"> + Delete + + + } + customActions={ + + {tagMap[item.id].map(tag => ( + {tag} + ))} + + } + > + {item.label} + + ))} + + ); + }, +}); diff --git a/packages/ui/src/components/ListBox/ListBox.tsx b/packages/ui/src/components/ListBox/ListBox.tsx index 221dd5fdb9..8bb24ac714 100644 --- a/packages/ui/src/components/ListBox/ListBox.tsx +++ b/packages/ui/src/components/ListBox/ListBox.tsx @@ -19,11 +19,13 @@ import { ListBoxItem as RAListBoxItem, Text, } from 'react-aria-components'; -import { RiCheckLine } from '@remixicon/react'; +import { RiCheckLine, RiMoreLine } from '@remixicon/react'; import { useDefinition } from '../../hooks/useDefinition'; import { ListBoxDefinition, ListBoxItemDefinition } from './definition'; import type { ListBoxProps, ListBoxItemProps } from './types'; import { Box } from '../Box/Box'; +import { ButtonIcon } from '../ButtonIcon'; +import { MenuTrigger, Menu } from '../Menu'; /** * A listbox displays a list of options and allows a user to select one or more of them. @@ -31,7 +33,10 @@ import { Box } from '../Box/Box'; * @public */ export const ListBox = (props: ListBoxProps) => { - const { ownProps, restProps } = useDefinition(ListBoxDefinition, props); + const { ownProps, restProps, dataAttributes } = useDefinition( + ListBoxDefinition, + props, + ); const { classes, items, children, renderEmptyState } = ownProps; return ( @@ -39,6 +44,7 @@ export const ListBox = (props: ListBoxProps) => { className={classes.root} items={items} renderEmptyState={renderEmptyState} + {...dataAttributes} {...restProps} > {children} @@ -52,8 +58,12 @@ export const ListBox = (props: ListBoxProps) => { * @public */ export const ListBoxItem = (props: ListBoxItemProps) => { - const { ownProps, restProps } = useDefinition(ListBoxItemDefinition, props); - const { classes, children, description, icon, customActions } = ownProps; + const { ownProps, restProps, dataAttributes } = useDefinition( + ListBoxItemDefinition, + props, + ); + const { classes, children, description, icon, menuItems, customActions } = + ownProps; const textValue = typeof children === 'string' ? children : undefined; @@ -61,6 +71,7 @@ export const ListBoxItem = (props: ListBoxItemProps) => { {({ isSelected }) => ( @@ -92,6 +103,23 @@ export const ListBoxItem = (props: ListBoxItemProps) => { {customActions} )} + {menuItems && ( +
e.stopPropagation()} + onKeyDown={e => e.stopPropagation()} + > + + } + size="small" + aria-label="More actions" + variant="tertiary" + /> + {menuItems} + +
+ )} )}
diff --git a/packages/ui/src/components/ListBox/definition.ts b/packages/ui/src/components/ListBox/definition.ts index ef8d6e4b61..95e97d582c 100644 --- a/packages/ui/src/components/ListBox/definition.ts +++ b/packages/ui/src/components/ListBox/definition.ts @@ -41,6 +41,7 @@ export const ListBoxDefinition = defineComponent()({ */ export const ListBoxItemDefinition = defineComponent()({ styles, + bg: 'consumer', classNames: { root: 'bui-ListBoxItem', check: 'bui-ListBoxItemCheck', @@ -53,6 +54,7 @@ export const ListBoxItemDefinition = defineComponent()({ children: {}, description: {}, icon: {}, + menuItems: {}, customActions: {}, className: {}, }, diff --git a/packages/ui/src/components/ListBox/types.ts b/packages/ui/src/components/ListBox/types.ts index 6299518467..fd2cc4146c 100644 --- a/packages/ui/src/components/ListBox/types.ts +++ b/packages/ui/src/components/ListBox/types.ts @@ -58,9 +58,15 @@ export type ListBoxItemOwnProps = { * Optional icon displayed before the label, rendered in a 32×32px box. */ icon?: React.ReactNode; + /** + * Optional menu items rendered inside an automatically managed dropdown menu. + * Pass `MenuItem` nodes here and the component will render the trigger button + * and menu wrapper for you. + */ + menuItems?: React.ReactNode; /** * Optional actions rendered in a flex row on the right side of the item, - * e.g. a ButtonIcon with a dropdown menu, or a set of tags. + * e.g. a set of tags. For a dropdown menu, prefer `menuItems`. */ customActions?: React.ReactNode; className?: string; diff --git a/packages/ui/src/recipes/CardsWithListBox.stories.tsx b/packages/ui/src/recipes/CardsWithListBox.stories.tsx new file mode 100644 index 0000000000..d979743525 --- /dev/null +++ b/packages/ui/src/recipes/CardsWithListBox.stories.tsx @@ -0,0 +1,240 @@ +/* + * 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 type { StoryFn } from '@storybook/react-vite'; +import { MemoryRouter } from 'react-router-dom'; +import { + Card, + CardHeader, + CardBody, + Container, + Grid, + Flex, + Text, + MenuItem, + TagGroup, + Tag, + ListBox, + ListBoxItem, +} from '..'; +import { + RiAccountCircleLine, + RiCloudLine, + RiCodeLine, + RiDeleteBinLine, + RiEdit2Line, + RiGitBranchLine, + RiJavascriptLine, + RiReactjsLine, + RiServerLine, + RiShareBoxLine, + RiShieldLine, + RiTerminalLine, +} from '@remixicon/react'; + +// --------------------------------------------------------------------------- +// Data +// --------------------------------------------------------------------------- + +interface ServiceItem { + id: string; + label: string; + description: string; + icon: React.ReactNode; + tags: string[]; +} + +const frontendServices: ServiceItem[] = [ + { + id: 'portal', + label: 'developer-portal', + description: 'Internal developer portal built on Backstage', + icon: , + tags: ['website', 'production'], + }, + { + id: 'design-system', + label: 'design-system', + description: 'Shared UI components and design tokens', + icon: , + tags: ['library', 'production'], + }, + { + id: 'docs-site', + label: 'docs-site', + description: 'Engineering documentation and runbooks', + icon: , + tags: ['website', 'production'], + }, + { + id: 'admin-ui', + label: 'admin-ui', + description: 'Internal tooling for platform administrators', + icon: , + tags: ['website', 'experimental'], + }, + { + id: 'onboarding-flow', + label: 'onboarding-flow', + description: 'New hire onboarding wizard and checklist', + icon: , + tags: ['website', 'experimental'], + }, +]; + +const backendServices: ServiceItem[] = [ + { + id: 'auth', + label: 'authentication-service', + description: 'Handles user authentication, sessions and token refresh', + icon: , + tags: ['service', 'production'], + }, + { + id: 'api-gateway', + label: 'api-gateway', + description: 'Routes and validates all inbound API requests', + icon: , + tags: ['service', 'production'], + }, + { + id: 'search', + label: 'search-indexer', + description: 'Indexes catalog entities for full-text search', + icon: , + tags: ['service', 'experimental'], + }, + { + id: 'ci-runner', + label: 'ci-runner', + description: 'Orchestrates and executes CI pipeline jobs', + icon: , + tags: ['service', 'production'], + }, + { + id: 'infra-provisioner', + label: 'infra-provisioner', + description: 'Terraform-based cloud resource provisioner', + icon: , + tags: ['service', 'experimental'], + }, +]; + +// --------------------------------------------------------------------------- +// Service list card +// --------------------------------------------------------------------------- + +interface ServiceListCardProps { + title: string; + items: ServiceItem[]; + description?: boolean; +} + +const ServiceListCard = ({ + title, + items, + description = false, +}: ServiceListCardProps) => ( + + + + + + {title} + + + + + + + {items.map(item => ( + + }>Edit + }>Share + } color="danger"> + Delete + + + } + customActions={ + + {item.tags.map(tag => ( + {tag} + ))} + + } + > + {item.label} + + ))} + + + +); + +const withRouter = (Story: StoryFn) => ( + + + +); + +const meta = preview.meta({ + title: 'Recipes/Cards with ListBox', + parameters: { + layout: 'fullscreen', + }, +}); + +export const Default = meta.story({ + decorators: [withRouter], + render: () => ( + + + + + + + ), +}); + +export const WithDescription = meta.story({ + args: { + description: true, + }, + render: () => ( + + + + + + + ), +}); From 668639722c71e7924c06c5c7b99e63e2e65cfc3a Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 15 Mar 2026 08:39:46 +0000 Subject: [PATCH 05/14] Cleanups Signed-off-by: Charles de Dreuille --- .changeset/add-listbox-component.md | 2 + .../components/list-box/props-definition.tsx | 10 +++ packages/ui/report.api.md | 90 +++++++++++++++++-- .../src/recipes/CardsWithListBox.stories.tsx | 6 +- 4 files changed, 98 insertions(+), 10 deletions(-) diff --git a/.changeset/add-listbox-component.md b/.changeset/add-listbox-component.md index 45faab32c5..af070bcbaa 100644 --- a/.changeset/add-listbox-component.md +++ b/.changeset/add-listbox-component.md @@ -3,3 +3,5 @@ --- 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. + +**Affected components:** ListBox, ListBoxItem diff --git a/docs-ui/src/app/components/list-box/props-definition.tsx b/docs-ui/src/app/components/list-box/props-definition.tsx index 861b211bc0..e2dcf2e5bf 100644 --- a/docs-ui/src/app/components/list-box/props-definition.tsx +++ b/docs-ui/src/app/components/list-box/props-definition.tsx @@ -67,6 +67,16 @@ export const listBoxItemPropDefs: Record = { type: 'boolean', description: 'Whether the item is disabled.', }, + menuItems: { + type: 'enum', + values: ['Iterable'], + description: 'Menu items displayed for this list box item.', + }, + customActions: { + type: 'enum', + values: ['ReactNode'], + description: 'Custom action elements displayed alongside the item.', + }, ...childrenPropDefs, ...classNamePropDefs, }; diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 834251f088..a6786fb16e 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -23,8 +23,8 @@ import type { HeadingProps } from 'react-aria-components'; import type { HTMLAttributes } from 'react'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import type { LinkProps as LinkProps_2 } from 'react-aria-components'; -import type { ListBoxItemProps } from 'react-aria-components'; -import type { ListBoxProps } from 'react-aria-components'; +import type { ListBoxItemProps as ListBoxItemProps_2 } from 'react-aria-components'; +import type { ListBoxProps as ListBoxProps_2 } from 'react-aria-components'; import type { MenuItemProps as MenuItemProps_2 } from 'react-aria-components'; import type { MenuProps as MenuProps_2 } from 'react-aria-components'; import type { MenuSectionProps as MenuSectionProps_2 } from 'react-aria-components'; @@ -1555,6 +1555,82 @@ export interface LinkProps extends Omit, LinkOwnProps {} +// @public +export const ListBox: ( + props: ListBoxProps, +) => JSX_2.Element; + +// @public +export const ListBoxDefinition: { + readonly styles: { + readonly [key: string]: string; + }; + readonly classNames: { + readonly root: 'bui-ListBox'; + }; + readonly propDefs: { + readonly items: {}; + readonly children: {}; + readonly renderEmptyState: {}; + readonly className: {}; + }; +}; + +// @public +export const ListBoxItem: (props: ListBoxItemProps) => JSX_2.Element; + +// @public +export const ListBoxItemDefinition: { + readonly styles: { + readonly [key: string]: string; + }; + readonly bg: 'consumer'; + readonly classNames: { + readonly root: 'bui-ListBoxItem'; + readonly check: 'bui-ListBoxItemCheck'; + readonly icon: 'bui-ListBoxItemIcon'; + readonly label: 'bui-ListBoxItemLabel'; + readonly description: 'bui-ListBoxItemDescription'; + readonly actions: 'bui-ListBoxItemActions'; + }; + readonly propDefs: { + readonly children: {}; + readonly description: {}; + readonly icon: {}; + readonly menuItems: {}; + readonly customActions: {}; + readonly className: {}; + }; +}; + +// @public +export type ListBoxItemOwnProps = { + children?: React.ReactNode; + description?: string; + icon?: React.ReactNode; + menuItems?: React.ReactNode; + customActions?: React.ReactNode; + className?: string; +}; + +// @public +export interface ListBoxItemProps + extends ListBoxItemOwnProps, + Omit {} + +// @public +export type ListBoxOwnProps = { + items?: ListBoxProps_2['items']; + children?: ListBoxProps_2['children']; + renderEmptyState?: ListBoxProps_2['renderEmptyState']; + className?: string; +}; + +// @public +export interface ListBoxProps + extends ListBoxOwnProps, + Omit, keyof ListBoxOwnProps> {} + // @public (undocumented) export interface MarginProps { // (undocumented) @@ -1589,13 +1665,13 @@ export const MenuAutocompleteListbox: ( // @public (undocumented) export type MenuAutocompleteListBoxOwnProps = MenuPopoverOwnProps & { placeholder?: string; - selectionMode?: ListBoxProps['selectionMode']; + selectionMode?: ListBoxProps_2['selectionMode']; }; // @public (undocumented) export interface MenuAutocompleteListBoxProps extends MenuAutocompleteListBoxOwnProps, - Omit, keyof MenuAutocompleteListBoxOwnProps> {} + Omit, keyof MenuAutocompleteListBoxOwnProps> {} // @public (undocumented) export type MenuAutocompleteOwnProps = MenuPopoverOwnProps & { @@ -1664,17 +1740,17 @@ export type MenuListBoxItemOwnProps = { // @public (undocumented) export interface MenuListBoxItemProps extends MenuListBoxItemOwnProps, - Omit {} + Omit {} // @public (undocumented) export type MenuListBoxOwnProps = MenuPopoverOwnProps & { - selectionMode?: ListBoxProps['selectionMode']; + selectionMode?: ListBoxProps_2['selectionMode']; }; // @public (undocumented) export interface MenuListBoxProps extends MenuListBoxOwnProps, - Omit, keyof MenuListBoxOwnProps> {} + Omit, keyof MenuListBoxOwnProps> {} // @public (undocumented) export type MenuOwnProps = MenuPopoverOwnProps; diff --git a/packages/ui/src/recipes/CardsWithListBox.stories.tsx b/packages/ui/src/recipes/CardsWithListBox.stories.tsx index d979743525..18d116f9e5 100644 --- a/packages/ui/src/recipes/CardsWithListBox.stories.tsx +++ b/packages/ui/src/recipes/CardsWithListBox.stories.tsx @@ -221,18 +221,18 @@ export const WithDescription = meta.story({ args: { description: true, }, - render: () => ( + render: args => ( From fa23437a875c36e9acc2bff95d4c959ac50c9682 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 15 Mar 2026 09:02:29 +0000 Subject: [PATCH 06/14] Update components.tsx Signed-off-by: Charles de Dreuille --- .../app/components/list-box/components.tsx | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/docs-ui/src/app/components/list-box/components.tsx b/docs-ui/src/app/components/list-box/components.tsx index 9db1ea57f3..ce0e0a9f03 100644 --- a/docs-ui/src/app/components/list-box/components.tsx +++ b/docs-ui/src/app/components/list-box/components.tsx @@ -4,16 +4,32 @@ import { ListBox, ListBoxItem, } from '../../../../../packages/ui/src/components/ListBox/ListBox'; +import { MenuItem } from '../../../../../packages/ui/src/components/Menu/Menu'; +import { + TagGroup, + Tag, +} from '../../../../../packages/ui/src/components/TagGroup/TagGroup'; import { useState } from 'react'; import type { Selection } from 'react-aria-components'; import { RiJavascriptLine, RiReactjsLine, - RiRustLine, + RiShipLine, RiTerminalLine, RiCodeLine, + RiDeleteBinLine, + RiEdit2Line, + RiShareBoxLine, } from '@remixicon/react'; +const itemTags: Record = { + react: ['frontend', 'ui'], + typescript: ['typed', 'js'], + javascript: ['web'], + rust: ['systems', 'fast'], + go: ['backend'], +}; + const items = [ { id: 'react', label: 'React' }, { id: 'typescript', label: 'TypeScript' }, @@ -54,14 +70,34 @@ const itemIcons: Record = { react: , typescript: , javascript: , - rust: , + rust: , go: , }; export const Default = () => ( - + {items.map(item => ( - + + }>Edit + }>Share + } color="danger"> + Delete + + + } + customActions={ + + {itemTags[item.id].map(tag => ( + {tag} + ))} + + } + > {item.label} ))} From a8dfc35a9ed482edf584e9cd13f45e9ca920bc6d Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 15 Mar 2026 17:41:05 +0000 Subject: [PATCH 07/14] Update page.mdx Signed-off-by: Charles de Dreuille --- docs-ui/src/app/components/list-box/page.mdx | 29 +++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/docs-ui/src/app/components/list-box/page.mdx b/docs-ui/src/app/components/list-box/page.mdx index e0340dbc38..90c7e23bf0 100644 --- a/docs-ui/src/app/components/list-box/page.mdx +++ b/docs-ui/src/app/components/list-box/page.mdx @@ -22,7 +22,10 @@ import { } from './snippets'; import { PageTitle } from '@/components/PageTitle'; import { Theming } from '@/components/Theming'; -import { ListBoxDefinition, ListBoxItemDefinition } from '../../../utils/definitions'; +import { + ListBoxDefinition, + ListBoxItemDefinition, +} from '../../../utils/definitions'; import { ChangelogComponent } from '@/components/ChangelogComponent'; export const reactAriaUrls = { @@ -66,15 +69,33 @@ Individual item within a ListBox. ### With description -} code={withDescription} /> +} + code={withDescription} +/> ### Single selection -} code={selectionModeSingle} /> +} + code={selectionModeSingle} +/> ### Multiple selection -} code={selectionModeMultiple} /> +} + code={selectionModeMultiple} +/> ### Disabled items From b2110c19c67bfd0a9e01b6fffdfcbcc1e7f08535 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sun, 15 Mar 2026 19:10:58 +0000 Subject: [PATCH 08/14] Update CardsWithListBox.stories.tsx Signed-off-by: Charles de Dreuille --- .../src/recipes/CardsWithListBox.stories.tsx | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/recipes/CardsWithListBox.stories.tsx b/packages/ui/src/recipes/CardsWithListBox.stories.tsx index 18d116f9e5..316159211a 100644 --- a/packages/ui/src/recipes/CardsWithListBox.stories.tsx +++ b/packages/ui/src/recipes/CardsWithListBox.stories.tsx @@ -142,18 +142,20 @@ interface ServiceListCardProps { title: string; items: ServiceItem[]; description?: boolean; + icons?: boolean; } const ServiceListCard = ({ title, items, description = false, + icons = true, }: ServiceListCardProps) => ( - + {title} @@ -165,7 +167,7 @@ const ServiceListCard = ({ @@ -217,6 +219,32 @@ export const Default = meta.story({ ), }); +export const WithNoIcons = meta.story({ + decorators: [withRouter], + args: { + icons: false, + description: true, + }, + render: args => ( + + + + + + + ), +}); + export const WithDescription = meta.story({ args: { description: true, From 0b0e642d8897fd1921163263e46eeb8165c726e5 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 16 Mar 2026 09:44:25 +0000 Subject: [PATCH 09/14] Update types.ts Signed-off-by: Charles de Dreuille --- packages/ui/src/components/ListBox/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/components/ListBox/types.ts b/packages/ui/src/components/ListBox/types.ts index fd2cc4146c..0d4eb9d045 100644 --- a/packages/ui/src/components/ListBox/types.ts +++ b/packages/ui/src/components/ListBox/types.ts @@ -57,7 +57,7 @@ export type ListBoxItemOwnProps = { /** * Optional icon displayed before the label, rendered in a 32×32px box. */ - icon?: React.ReactNode; + icon?: React.ReactElement; /** * Optional menu items rendered inside an automatically managed dropdown menu. * Pass `MenuItem` nodes here and the component will render the trigger button From af4610d138bc32424b41670faf0c46873926a9ab Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 16 Mar 2026 09:52:45 +0000 Subject: [PATCH 10/14] Fix types Signed-off-by: Charles de Dreuille --- packages/ui/src/components/ListBox/ListBox.stories.tsx | 2 +- packages/ui/src/recipes/CardsWithListBox.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ui/src/components/ListBox/ListBox.stories.tsx b/packages/ui/src/components/ListBox/ListBox.stories.tsx index 2ad1429272..073a0073e7 100644 --- a/packages/ui/src/components/ListBox/ListBox.stories.tsx +++ b/packages/ui/src/components/ListBox/ListBox.stories.tsx @@ -84,7 +84,7 @@ const itemsWithDescription = [ }, ]; -const itemIcons: Record = { +const itemIcons: Record = { react: , typescript: , javascript: , diff --git a/packages/ui/src/recipes/CardsWithListBox.stories.tsx b/packages/ui/src/recipes/CardsWithListBox.stories.tsx index 316159211a..3a41dfb346 100644 --- a/packages/ui/src/recipes/CardsWithListBox.stories.tsx +++ b/packages/ui/src/recipes/CardsWithListBox.stories.tsx @@ -54,7 +54,7 @@ interface ServiceItem { id: string; label: string; description: string; - icon: React.ReactNode; + icon: React.ReactElement; tags: string[]; } From 985788ff6d01e30e8992c237e158bbc6f2a32a6b Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 16 Mar 2026 12:05:48 +0000 Subject: [PATCH 11/14] Move to GridList Signed-off-by: Charles de Dreuille --- .changeset/add-listbox-component.md | 4 +- .../app/components/list-box/components.tsx | 186 ---------- .../src/app/components/list-box/snippets.ts | 75 ---- .../src/app/components/list/components.tsx | 159 ++++++++ .../components/{list-box => list}/page.mdx | 32 +- .../{list-box => list}/props-definition.tsx | 20 +- docs-ui/src/app/components/list/snippets.ts | 72 ++++ docs-ui/src/utils/data.ts | 4 +- packages/ui/report.api.md | 76 ++-- .../List.module.css} | 14 +- .../ui/src/components/List/List.stories.tsx | 290 +++++++++++++++ .../{ListBox/ListBox.tsx => List/List.tsx} | 45 +-- .../{ListBox => List}/definition.ts | 26 +- .../src/components/{ListBox => List}/index.ts | 12 +- .../src/components/{ListBox => List}/types.ts | 38 +- .../components/ListBox/ListBox.stories.tsx | 346 ------------------ packages/ui/src/definitions.ts | 6 +- packages/ui/src/index.ts | 2 +- ....stories.tsx => CardsWithList.stories.tsx} | 14 +- 19 files changed, 664 insertions(+), 757 deletions(-) delete mode 100644 docs-ui/src/app/components/list-box/components.tsx delete mode 100644 docs-ui/src/app/components/list-box/snippets.ts create mode 100644 docs-ui/src/app/components/list/components.tsx rename docs-ui/src/app/components/{list-box => list}/page.mdx (68%) rename docs-ui/src/app/components/{list-box => list}/props-definition.tsx (74%) create mode 100644 docs-ui/src/app/components/list/snippets.ts rename packages/ui/src/components/{ListBox/ListBox.module.css => List/List.module.css} (96%) create mode 100644 packages/ui/src/components/List/List.stories.tsx rename packages/ui/src/components/{ListBox/ListBox.tsx => List/List.tsx} (72%) rename packages/ui/src/components/{ListBox => List}/definition.ts (64%) rename packages/ui/src/components/{ListBox => List}/index.ts (76%) rename packages/ui/src/components/{ListBox => List}/types.ts (64%) delete mode 100644 packages/ui/src/components/ListBox/ListBox.stories.tsx rename packages/ui/src/recipes/{CardsWithListBox.stories.tsx => CardsWithList.stories.tsx} (97%) diff --git a/.changeset/add-listbox-component.md b/.changeset/add-listbox-component.md index af070bcbaa..4c510e61f9 100644 --- a/.changeset/add-listbox-component.md +++ b/.changeset/add-listbox-component.md @@ -2,6 +2,6 @@ '@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. +Added `List` and `ListRow` components. These provide a standalone, accessible list of interactive rows built on top of React Aria's `GridList` and `GridListItem` primitives. Rows support icons, descriptions, actions, menus, and single or multiple selection modes. -**Affected components:** ListBox, ListBoxItem +**Affected components:** List, ListRow diff --git a/docs-ui/src/app/components/list-box/components.tsx b/docs-ui/src/app/components/list-box/components.tsx deleted file mode 100644 index ce0e0a9f03..0000000000 --- a/docs-ui/src/app/components/list-box/components.tsx +++ /dev/null @@ -1,186 +0,0 @@ -'use client'; - -import { - ListBox, - ListBoxItem, -} from '../../../../../packages/ui/src/components/ListBox/ListBox'; -import { MenuItem } from '../../../../../packages/ui/src/components/Menu/Menu'; -import { - TagGroup, - Tag, -} from '../../../../../packages/ui/src/components/TagGroup/TagGroup'; -import { useState } from 'react'; -import type { Selection } from 'react-aria-components'; -import { - RiJavascriptLine, - RiReactjsLine, - RiShipLine, - RiTerminalLine, - RiCodeLine, - RiDeleteBinLine, - RiEdit2Line, - RiShareBoxLine, -} from '@remixicon/react'; - -const itemTags: Record = { - react: ['frontend', 'ui'], - typescript: ['typed', 'js'], - javascript: ['web'], - rust: ['systems', 'fast'], - go: ['backend'], -}; - -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 => ( - - }>Edit - }>Share - } color="danger"> - Delete - - - } - customActions={ - - {itemTags[item.id].map(tag => ( - {tag} - ))} - - } - > - {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/snippets.ts b/docs-ui/src/app/components/list-box/snippets.ts deleted file mode 100644 index cd405a3154..0000000000 --- a/docs-ui/src/app/components/list-box/snippets.ts +++ /dev/null @@ -1,75 +0,0 @@ -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/app/components/list/components.tsx b/docs-ui/src/app/components/list/components.tsx new file mode 100644 index 0000000000..5630027f94 --- /dev/null +++ b/docs-ui/src/app/components/list/components.tsx @@ -0,0 +1,159 @@ +'use client'; + +import { + List, + ListRow, +} from '../../../../../packages/ui/src/components/List/List'; +import { MenuItem } from '../../../../../packages/ui/src/components/Menu/Menu'; +import { + TagGroup, + Tag, +} from '../../../../../packages/ui/src/components/TagGroup/TagGroup'; +import { useState } from 'react'; +import type { Selection } from 'react-aria-components'; +import { + RiJavascriptLine, + RiReactjsLine, + RiShipLine, + RiTerminalLine, + RiCodeLine, + RiDeleteBinLine, + RiEdit2Line, + RiShareBoxLine, +} from '@remixicon/react'; + +const items = [ + { + id: 'react', + label: 'React', + description: 'A JavaScript library for building user interfaces', + icon: , + tags: ['frontend', 'ui'], + }, + { + id: 'typescript', + label: 'TypeScript', + description: 'Typed superset of JavaScript', + icon: , + tags: ['typed', 'js'], + }, + { + id: 'javascript', + label: 'JavaScript', + description: 'The language of the web', + icon: , + tags: ['web'], + }, + { + id: 'rust', + label: 'Rust', + description: 'Systems programming with memory safety', + icon: , + tags: ['systems', 'fast'], + }, + { + id: 'go', + label: 'Go', + description: 'Simple, fast, and reliable', + icon: , + tags: ['backend'], + }, +]; + +const menuItems = ( + <> + }>Edit + }>Share + } color="danger"> + Delete + + +); + +export const Default = () => ( + + {item => ( + + {item.tags.map(tag => ( + {tag} + ))} + + } + > + {item.label} + + )} + +); + +export const WithIcons = () => ( + + {item => ( + + {item.label} + + )} + +); + +export const WithDescription = () => ( + + {item => ( + + {item.label} + + )} + +); + +export const SelectionModeSingle = () => { + const [selected, setSelected] = useState(new Set(['react'])); + + return ( + + {item => {item.label}} + + ); +}; + +export const SelectionModeMultiple = () => { + const [selected, setSelected] = useState( + new Set(['react', 'typescript']), + ); + + return ( + + {item => {item.label}} + + ); +}; + +export const Disabled = () => ( + + {item => {item.label}} + +); diff --git a/docs-ui/src/app/components/list-box/page.mdx b/docs-ui/src/app/components/list/page.mdx similarity index 68% rename from docs-ui/src/app/components/list-box/page.mdx rename to docs-ui/src/app/components/list/page.mdx index 90c7e23bf0..5ec90146cb 100644 --- a/docs-ui/src/app/components/list-box/page.mdx +++ b/docs-ui/src/app/components/list/page.mdx @@ -10,7 +10,7 @@ import { SelectionModeMultiple, Disabled, } from './components'; -import { listBoxPropDefs, listBoxItemPropDefs } from './props-definition'; +import { listPropDefs, listRowPropDefs } from './props-definition'; import { usage, preview, @@ -23,18 +23,18 @@ import { import { PageTitle } from '@/components/PageTitle'; import { Theming } from '@/components/Theming'; import { - ListBoxDefinition, - ListBoxItemDefinition, + ListDefinition, + ListRowDefinition, } from '../../../utils/definitions'; import { ChangelogComponent } from '@/components/ChangelogComponent'; export const reactAriaUrls = { - listBox: 'https://react-aria.adobe.com/ListBox', + gridList: 'https://react-aria.adobe.com/GridList', }; } code={preview} /> @@ -45,21 +45,21 @@ export const reactAriaUrls = { ## API reference -### ListBox +### List -Container for a list of selectable options. +Container for a list of interactive rows. - + - + -### ListBoxItem +### ListRow -Individual item within a ListBox. +Individual row within a List. - + - + ## Examples @@ -101,6 +101,6 @@ Individual item within a ListBox. } code={disabled} /> - + - + diff --git a/docs-ui/src/app/components/list-box/props-definition.tsx b/docs-ui/src/app/components/list/props-definition.tsx similarity index 74% rename from docs-ui/src/app/components/list-box/props-definition.tsx rename to docs-ui/src/app/components/list/props-definition.tsx index e2dcf2e5bf..b021fc6e0a 100644 --- a/docs-ui/src/app/components/list-box/props-definition.tsx +++ b/docs-ui/src/app/components/list/props-definition.tsx @@ -4,7 +4,7 @@ import { type PropDef, } from '@/utils/propDefs'; -export const listBoxPropDefs: Record = { +export const listPropDefs: Record = { items: { type: 'enum', values: ['Iterable'], @@ -12,7 +12,7 @@ export const listBoxPropDefs: Record = { }, renderEmptyState: { type: 'enum', - values: ['() => ReactNode'], + values: ['(props: GridListRenderProps) => ReactNode'], description: 'Content to display when the collection is empty.', }, selectionMode: { @@ -44,10 +44,10 @@ export const listBoxPropDefs: Record = { ...classNamePropDefs, }; -export const listBoxItemPropDefs: Record = { +export const listRowPropDefs: Record = { id: { type: 'string', - description: 'Unique identifier for the item.', + description: 'Unique identifier for the row.', }, textValue: { type: 'string', @@ -57,7 +57,7 @@ export const listBoxItemPropDefs: Record = { icon: { type: 'enum', values: ['ReactNode'], - description: 'Icon displayed before the item label.', + description: 'Icon displayed before the row label.', }, description: { type: 'string', @@ -65,17 +65,19 @@ export const listBoxItemPropDefs: Record = { }, isDisabled: { type: 'boolean', - description: 'Whether the item is disabled.', + description: 'Whether the row is disabled.', }, menuItems: { type: 'enum', - values: ['Iterable'], - description: 'Menu items displayed for this list box item.', + values: ['ReactNode'], + description: + 'Menu items rendered inside an automatically managed dropdown. Pass MenuItem nodes.', }, customActions: { type: 'enum', values: ['ReactNode'], - description: 'Custom action elements displayed alongside the item.', + description: + 'Custom action elements displayed on the right side of the row, e.g. tags.', }, ...childrenPropDefs, ...classNamePropDefs, diff --git a/docs-ui/src/app/components/list/snippets.ts b/docs-ui/src/app/components/list/snippets.ts new file mode 100644 index 0000000000..ccaaf7e28d --- /dev/null +++ b/docs-ui/src/app/components/list/snippets.ts @@ -0,0 +1,72 @@ +export const usage = `import { List, ListRow } from '@backstage/ui'; + + + {item => {item.label}} +`; + +export const preview = ` + {item => ( + + {item.tags.map(tag => ( + {tag} + ))} + + } + > + {item.label} + + )} +`; + +export const withIcons = ` + {item => ( + + {item.label} + + )} +`; + +export const withDescription = ` + {item => ( + + {item.label} + + )} +`; + +export const selectionModeSingle = `const [selected, setSelected] = useState(new Set(['react'])); + + + {item => {item.label}} +`; + +export const selectionModeMultiple = `const [selected, setSelected] = useState(new Set(['react', 'typescript'])); + + + {item => {item.label}} +`; + +export const disabled = ` + {item => {item.label}} +`; diff --git a/docs-ui/src/utils/data.ts b/docs-ui/src/utils/data.ts index 5ade525e9b..a184ca162b 100644 --- a/docs-ui/src/utils/data.ts +++ b/docs-ui/src/utils/data.ts @@ -70,8 +70,8 @@ export const components: Page[] = [ slug: 'link', }, { - title: 'ListBox', - slug: 'list-box', + title: 'List', + slug: 'list', }, { title: 'Menu', diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index a6786fb16e..3078af5a18 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -19,12 +19,14 @@ import type { DisclosurePanelProps } from 'react-aria-components'; import type { DisclosureProps } from 'react-aria-components'; import type { ElementType } from 'react'; import { ForwardRefExoticComponent } from 'react'; +import type { GridListItemProps } from 'react-aria-components'; +import type { GridListProps } from 'react-aria-components'; import type { HeadingProps } from 'react-aria-components'; import type { HTMLAttributes } from 'react'; import { JSX as JSX_2 } from 'react/jsx-runtime'; import type { LinkProps as LinkProps_2 } from 'react-aria-components'; -import type { ListBoxItemProps as ListBoxItemProps_2 } from 'react-aria-components'; -import type { ListBoxProps as ListBoxProps_2 } from 'react-aria-components'; +import type { ListBoxItemProps } from 'react-aria-components'; +import type { ListBoxProps } from 'react-aria-components'; import type { MenuItemProps as MenuItemProps_2 } from 'react-aria-components'; import type { MenuProps as MenuProps_2 } from 'react-aria-components'; import type { MenuSectionProps as MenuSectionProps_2 } from 'react-aria-components'; @@ -1556,17 +1558,15 @@ export interface LinkProps LinkOwnProps {} // @public -export const ListBox: ( - props: ListBoxProps, -) => JSX_2.Element; +export const List: (props: ListProps) => JSX_2.Element; // @public -export const ListBoxDefinition: { +export const ListDefinition: { readonly styles: { readonly [key: string]: string; }; readonly classNames: { - readonly root: 'bui-ListBox'; + readonly root: 'bui-List'; }; readonly propDefs: { readonly items: {}; @@ -1577,21 +1577,34 @@ export const ListBoxDefinition: { }; // @public -export const ListBoxItem: (props: ListBoxItemProps) => JSX_2.Element; +export type ListOwnProps = { + items?: GridListProps['items']; + children?: GridListProps['children']; + renderEmptyState?: GridListProps['renderEmptyState']; + className?: string; +}; // @public -export const ListBoxItemDefinition: { +export interface ListProps + extends ListOwnProps, + Omit, keyof ListOwnProps> {} + +// @public +export const ListRow: (props: ListRowProps) => JSX_2.Element; + +// @public +export const ListRowDefinition: { readonly styles: { readonly [key: string]: string; }; readonly bg: 'consumer'; readonly classNames: { - readonly root: 'bui-ListBoxItem'; - readonly check: 'bui-ListBoxItemCheck'; - readonly icon: 'bui-ListBoxItemIcon'; - readonly label: 'bui-ListBoxItemLabel'; - readonly description: 'bui-ListBoxItemDescription'; - readonly actions: 'bui-ListBoxItemActions'; + readonly root: 'bui-ListRow'; + readonly check: 'bui-ListRowCheck'; + readonly icon: 'bui-ListRowIcon'; + readonly label: 'bui-ListRowLabel'; + readonly description: 'bui-ListRowDescription'; + readonly actions: 'bui-ListRowActions'; }; readonly propDefs: { readonly children: {}; @@ -1604,32 +1617,19 @@ export const ListBoxItemDefinition: { }; // @public -export type ListBoxItemOwnProps = { +export type ListRowOwnProps = { children?: React.ReactNode; description?: string; - icon?: React.ReactNode; + icon?: React.ReactElement; menuItems?: React.ReactNode; customActions?: React.ReactNode; className?: string; }; // @public -export interface ListBoxItemProps - extends ListBoxItemOwnProps, - Omit {} - -// @public -export type ListBoxOwnProps = { - items?: ListBoxProps_2['items']; - children?: ListBoxProps_2['children']; - renderEmptyState?: ListBoxProps_2['renderEmptyState']; - className?: string; -}; - -// @public -export interface ListBoxProps - extends ListBoxOwnProps, - Omit, keyof ListBoxOwnProps> {} +export interface ListRowProps + extends ListRowOwnProps, + Omit {} // @public (undocumented) export interface MarginProps { @@ -1665,13 +1665,13 @@ export const MenuAutocompleteListbox: ( // @public (undocumented) export type MenuAutocompleteListBoxOwnProps = MenuPopoverOwnProps & { placeholder?: string; - selectionMode?: ListBoxProps_2['selectionMode']; + selectionMode?: ListBoxProps['selectionMode']; }; // @public (undocumented) export interface MenuAutocompleteListBoxProps extends MenuAutocompleteListBoxOwnProps, - Omit, keyof MenuAutocompleteListBoxOwnProps> {} + Omit, keyof MenuAutocompleteListBoxOwnProps> {} // @public (undocumented) export type MenuAutocompleteOwnProps = MenuPopoverOwnProps & { @@ -1740,17 +1740,17 @@ export type MenuListBoxItemOwnProps = { // @public (undocumented) export interface MenuListBoxItemProps extends MenuListBoxItemOwnProps, - Omit {} + Omit {} // @public (undocumented) export type MenuListBoxOwnProps = MenuPopoverOwnProps & { - selectionMode?: ListBoxProps_2['selectionMode']; + selectionMode?: ListBoxProps['selectionMode']; }; // @public (undocumented) export interface MenuListBoxProps extends MenuListBoxOwnProps, - Omit, keyof MenuListBoxOwnProps> {} + Omit, keyof MenuListBoxOwnProps> {} // @public (undocumented) export type MenuOwnProps = MenuPopoverOwnProps; diff --git a/packages/ui/src/components/ListBox/ListBox.module.css b/packages/ui/src/components/List/List.module.css similarity index 96% rename from packages/ui/src/components/ListBox/ListBox.module.css rename to packages/ui/src/components/List/List.module.css index ea323b9471..f41b086618 100644 --- a/packages/ui/src/components/ListBox/ListBox.module.css +++ b/packages/ui/src/components/List/List.module.css @@ -17,7 +17,7 @@ @layer tokens, base, components, utilities; @layer components { - .bui-ListBox { + .bui-List { box-sizing: border-box; overflow-y: auto; outline: none; @@ -35,7 +35,7 @@ } } - .bui-ListBoxItem { + .bui-ListRow { box-sizing: border-box; display: flex; align-items: center; @@ -129,7 +129,7 @@ } } - .bui-ListBoxItemCheck { + .bui-ListRowCheck { display: flex; align-items: center; justify-content: center; @@ -143,7 +143,7 @@ } } - .bui-ListBoxItemIcon { + .bui-ListRowIcon { display: flex; align-items: center; justify-content: center; @@ -159,7 +159,7 @@ } } - .bui-ListBoxItemLabel { + .bui-ListRowLabel { flex: 1; display: flex; flex-direction: column; @@ -174,7 +174,7 @@ } } - .bui-ListBoxItemDescription { + .bui-ListRowDescription { font-size: var(--bui-font-size-2); color: var(--bui-fg-secondary); overflow: hidden; @@ -182,7 +182,7 @@ text-overflow: ellipsis; } - .bui-ListBoxItemActions { + .bui-ListRowActions { display: flex; align-items: center; gap: var(--bui-space-1); diff --git a/packages/ui/src/components/List/List.stories.tsx b/packages/ui/src/components/List/List.stories.tsx new file mode 100644 index 0000000000..224315ddac --- /dev/null +++ b/packages/ui/src/components/List/List.stories.tsx @@ -0,0 +1,290 @@ +/* + * 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 { List, ListRow } from './List'; +import { MenuItem } from '../Menu'; +import { TagGroup, Tag } from '../TagGroup'; +import type { Selection } from 'react-aria-components'; +import { + RiJavascriptLine, + RiReactjsLine, + RiShipLine, + RiTerminalLine, + RiCodeLine, + RiDeleteBinLine, + RiEdit2Line, + RiShareBoxLine, +} from '@remixicon/react'; +import { MemoryRouter } from 'react-router-dom'; + +const meta = preview.meta({ + title: 'Backstage UI/List', + component: List, + args: { + style: { width: 320 }, + 'aria-label': 'List', + }, + decorators: [ + Story => ( + + + + ), + ], +}); + +const items = [ + { + id: 'react', + label: 'React', + description: 'A JavaScript library for building user interfaces', + icon: , + tags: ['frontend', 'ui'], + }, + { + id: 'typescript', + label: 'TypeScript', + description: 'Typed superset of JavaScript', + icon: , + tags: ['typed', 'js'], + }, + { + id: 'javascript', + label: 'JavaScript', + description: 'The language of the web', + icon: , + tags: ['web'], + }, + { + id: 'rust', + label: 'Rust', + description: 'Systems programming with memory safety', + icon: , + tags: ['systems', 'fast'], + }, + { + id: 'go', + label: 'Go', + description: 'Simple, fast, and reliable', + icon: , + tags: ['backend'], + }, +]; + +const menuItems = ( + <> + }>Edit + }>Share + } color="danger"> + Delete + + +); + +export const Default = meta.story({ + render: args => ( + + {item => {item.label}} + + ), +}); + +export const WithIcons = meta.story({ + render: args => ( + + {item => ( + + {item.label} + + )} + + ), +}); + +export const WithDescription = meta.story({ + args: { + style: { width: 340 }, + }, + render: args => ( + + {item => ( + + {item.label} + + )} + + ), +}); + +export const SelectionModeSingle = meta.story({ + render: args => { + const [selected, setSelected] = useState(new Set(['react'])); + + return ( + + {item => {item.label}} + + ); + }, +}); + +export const SelectionModeSingleWithIcons = meta.story({ + render: args => { + const [selected, setSelected] = useState(new Set(['react'])); + + return ( + + {item => ( + + {item.label} + + )} + + ); + }, +}); + +export const SelectionModeMultiple = meta.story({ + render: args => { + const [selected, setSelected] = useState( + new Set(['react', 'typescript']), + ); + + return ( + + {item => {item.label}} + + ); + }, +}); + +export const SelectionModeMultipleWithIcons = meta.story({ + render: args => { + const [selected, setSelected] = useState( + new Set(['react', 'typescript']), + ); + + return ( + + {item => ( + + {item.label} + + )} + + ); + }, +}); + +export const Disabled = meta.story({ + render: args => ( + + {item => {item.label}} + + ), +}); + +export const WithActionsMenu = meta.story({ + args: { + style: { width: 420 }, + }, + render: args => ( + + {item => ( + + {item.label} + + )} + + ), +}); + +export const WithActionsTags = meta.story({ + args: { + style: { width: 420 }, + }, + render: args => ( + + {item => ( + + {item.tags.map(tag => ( + {tag} + ))} + + } + > + {item.label} + + )} + + ), +}); + +export const WithActionsMenuAndTags = meta.story({ + args: { + style: { width: 420 }, + }, + render: args => ( + + {item => ( + + {item.tags.map(tag => ( + {tag} + ))} + + } + > + {item.label} + + )} + + ), +}); diff --git a/packages/ui/src/components/ListBox/ListBox.tsx b/packages/ui/src/components/List/List.tsx similarity index 72% rename from packages/ui/src/components/ListBox/ListBox.tsx rename to packages/ui/src/components/List/List.tsx index 8bb24ac714..a8aeaa6727 100644 --- a/packages/ui/src/components/ListBox/ListBox.tsx +++ b/packages/ui/src/components/List/List.tsx @@ -15,32 +15,33 @@ */ import { - ListBox as RAListBox, - ListBoxItem as RAListBoxItem, + GridList as RAGridList, + GridListItem as RAGridListItem, Text, } from 'react-aria-components'; import { RiCheckLine, RiMoreLine } from '@remixicon/react'; import { useDefinition } from '../../hooks/useDefinition'; -import { ListBoxDefinition, ListBoxItemDefinition } from './definition'; -import type { ListBoxProps, ListBoxItemProps } from './types'; +import { ListDefinition, ListRowDefinition } from './definition'; +import type { ListProps, ListRowProps } from './types'; import { Box } from '../Box/Box'; import { ButtonIcon } from '../ButtonIcon'; import { MenuTrigger, Menu } from '../Menu'; /** - * A listbox displays a list of options and allows a user to select one or more of them. + * A list displays a list of interactive rows with support for keyboard + * navigation, single or multiple selection, and row actions. * * @public */ -export const ListBox = (props: ListBoxProps) => { +export const List = (props: ListProps) => { const { ownProps, restProps, dataAttributes } = useDefinition( - ListBoxDefinition, + ListDefinition, props, ); const { classes, items, children, renderEmptyState } = ownProps; return ( - (props: ListBoxProps) => { {...restProps} > {children} - + ); }; /** - * An item within a ListBox. + * A row within a List. * * @public */ -export const ListBoxItem = (props: ListBoxItemProps) => { +export const ListRow = (props: ListRowProps) => { const { ownProps, restProps, dataAttributes } = useDefinition( - ListBoxItemDefinition, + ListRowDefinition, props, ); const { classes, children, description, icon, menuItems, customActions } = @@ -68,7 +69,7 @@ export const ListBoxItem = (props: ListBoxItemProps) => { const textValue = typeof children === 'string' ? children : undefined; return ( - { )}
- {children} + {children} {description && ( {description} @@ -95,20 +96,10 @@ export const ListBoxItem = (props: ListBoxItemProps) => { )}
{customActions && ( -
e.stopPropagation()} - onKeyDown={e => e.stopPropagation()} - > - {customActions} -
+
{customActions}
)} {menuItems && ( -
e.stopPropagation()} - onKeyDown={e => e.stopPropagation()} - > +
} @@ -122,6 +113,6 @@ export const ListBoxItem = (props: ListBoxItemProps) => { )} )} - + ); }; diff --git a/packages/ui/src/components/ListBox/definition.ts b/packages/ui/src/components/List/definition.ts similarity index 64% rename from packages/ui/src/components/ListBox/definition.ts rename to packages/ui/src/components/List/definition.ts index 95e97d582c..98b9e24fc6 100644 --- a/packages/ui/src/components/ListBox/definition.ts +++ b/packages/ui/src/components/List/definition.ts @@ -15,17 +15,17 @@ */ import { defineComponent } from '../../hooks/useDefinition'; -import type { ListBoxOwnProps, ListBoxItemOwnProps } from './types'; -import styles from './ListBox.module.css'; +import type { ListOwnProps, ListRowOwnProps } from './types'; +import styles from './List.module.css'; /** - * Component definition for ListBox + * Component definition for List * @public */ -export const ListBoxDefinition = defineComponent()({ +export const ListDefinition = defineComponent()({ styles, classNames: { - root: 'bui-ListBox', + root: 'bui-List', }, propDefs: { items: {}, @@ -36,19 +36,19 @@ export const ListBoxDefinition = defineComponent()({ }); /** - * Component definition for ListBoxItem + * Component definition for ListRow * @public */ -export const ListBoxItemDefinition = defineComponent()({ +export const ListRowDefinition = defineComponent()({ styles, bg: 'consumer', classNames: { - root: 'bui-ListBoxItem', - check: 'bui-ListBoxItemCheck', - icon: 'bui-ListBoxItemIcon', - label: 'bui-ListBoxItemLabel', - description: 'bui-ListBoxItemDescription', - actions: 'bui-ListBoxItemActions', + root: 'bui-ListRow', + check: 'bui-ListRowCheck', + icon: 'bui-ListRowIcon', + label: 'bui-ListRowLabel', + description: 'bui-ListRowDescription', + actions: 'bui-ListRowActions', }, propDefs: { children: {}, diff --git a/packages/ui/src/components/ListBox/index.ts b/packages/ui/src/components/List/index.ts similarity index 76% rename from packages/ui/src/components/ListBox/index.ts rename to packages/ui/src/components/List/index.ts index fc3d35facd..e52739c04e 100644 --- a/packages/ui/src/components/ListBox/index.ts +++ b/packages/ui/src/components/List/index.ts @@ -14,11 +14,11 @@ * limitations under the License. */ -export { ListBox, ListBoxItem } from './ListBox'; +export { List, ListRow } from './List'; export type { - ListBoxProps, - ListBoxOwnProps, - ListBoxItemProps, - ListBoxItemOwnProps, + ListProps, + ListOwnProps, + ListRowProps, + ListRowOwnProps, } from './types'; -export { ListBoxDefinition, ListBoxItemDefinition } from './definition'; +export { ListDefinition, ListRowDefinition } from './definition'; diff --git a/packages/ui/src/components/ListBox/types.ts b/packages/ui/src/components/List/types.ts similarity index 64% rename from packages/ui/src/components/ListBox/types.ts rename to packages/ui/src/components/List/types.ts index 0d4eb9d045..1bd5ad1037 100644 --- a/packages/ui/src/components/ListBox/types.ts +++ b/packages/ui/src/components/List/types.ts @@ -15,39 +15,39 @@ */ import type { - ListBoxProps as ReactAriaListBoxProps, - ListBoxItemProps as ReactAriaListBoxItemProps, + GridListProps as ReactAriaGridListProps, + GridListItemProps as ReactAriaGridListItemProps, } from 'react-aria-components'; /** - * Own props for the ListBox component. + * Own props for the List component. * * @public */ -export type ListBoxOwnProps = { - items?: ReactAriaListBoxProps['items']; - children?: ReactAriaListBoxProps['children']; - renderEmptyState?: ReactAriaListBoxProps['renderEmptyState']; +export type ListOwnProps = { + items?: ReactAriaGridListProps['items']; + children?: ReactAriaGridListProps['children']; + renderEmptyState?: ReactAriaGridListProps['renderEmptyState']; className?: string; }; /** - * Props for the ListBox component. + * Props for the List component. * * @public */ -export interface ListBoxProps - extends ListBoxOwnProps, - Omit, keyof ListBoxOwnProps> {} +export interface ListProps + extends ListOwnProps, + Omit, keyof ListOwnProps> {} /** - * Own props for the ListBoxItem component. + * Own props for the ListRow component. * * @public */ -export type ListBoxItemOwnProps = { +export type ListRowOwnProps = { /** - * The main label content of the item. + * The main label content of the row. */ children?: React.ReactNode; /** @@ -65,7 +65,7 @@ export type ListBoxItemOwnProps = { */ menuItems?: React.ReactNode; /** - * Optional actions rendered in a flex row on the right side of the item, + * Optional actions rendered in a flex row on the right side of the row, * e.g. a set of tags. For a dropdown menu, prefer `menuItems`. */ customActions?: React.ReactNode; @@ -73,10 +73,10 @@ export type ListBoxItemOwnProps = { }; /** - * Props for the ListBoxItem component. + * Props for the ListRow component. * * @public */ -export interface ListBoxItemProps - extends ListBoxItemOwnProps, - Omit {} +export interface ListRowProps + extends ListRowOwnProps, + Omit {} diff --git a/packages/ui/src/components/ListBox/ListBox.stories.tsx b/packages/ui/src/components/ListBox/ListBox.stories.tsx deleted file mode 100644 index 073a0073e7..0000000000 --- a/packages/ui/src/components/ListBox/ListBox.stories.tsx +++ /dev/null @@ -1,346 +0,0 @@ -/* - * 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 { MenuItem } from '../Menu'; -import { TagGroup, Tag } from '../TagGroup'; -import type { Selection } from 'react-aria-components'; -import { - RiJavascriptLine, - RiReactjsLine, - RiShipLine, - RiTerminalLine, - RiCodeLine, - RiDeleteBinLine, - RiEdit2Line, - RiShareBoxLine, -} from '@remixicon/react'; -import { MemoryRouter } from 'react-router-dom'; - -const meta = preview.meta({ - title: 'Backstage UI/ListBox', - component: ListBox, - args: { - style: { width: 320 }, - 'aria-label': 'List', - }, - decorators: [ - Story => ( - - - - ), - ], -}); - -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 SelectionModeSingleWithIcons = 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 SelectionModeMultipleWithIcons = 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} - - ))} - - ), -}); - -export const WithActionsMenu = meta.story({ - args: { - style: { width: 420 }, - }, - render: args => ( - - {items.map(item => ( - - }>Edit - }>Share - } color="danger"> - Delete - - - } - > - {item.label} - - ))} - - ), -}); - -export const WithActionsTags = meta.story({ - args: { - style: { width: 420 }, - }, - render: args => { - const tagMap: Record = { - react: ['frontend', 'ui'], - typescript: ['typed', 'js'], - javascript: ['web'], - rust: ['systems', 'fast'], - go: ['backend'], - }; - - return ( - - {items.map(item => ( - - {tagMap[item.id].map(tag => ( - {tag} - ))} - - } - > - {item.label} - - ))} - - ); - }, -}); - -export const WithActionsMenuAndTags = meta.story({ - args: { - style: { width: 420 }, - }, - render: args => { - const tagMap: Record = { - react: ['frontend', 'ui'], - typescript: ['typed', 'js'], - javascript: ['web'], - rust: ['systems', 'fast'], - go: ['backend'], - }; - - return ( - - {items.map(item => ( - - }>Edit - }>Share - } color="danger"> - Delete - - - } - customActions={ - - {tagMap[item.id].map(tag => ( - {tag} - ))} - - } - > - {item.label} - - ))} - - ); - }, -}); diff --git a/packages/ui/src/definitions.ts b/packages/ui/src/definitions.ts index 1d4d82ff41..a4876ce177 100644 --- a/packages/ui/src/definitions.ts +++ b/packages/ui/src/definitions.ts @@ -49,9 +49,9 @@ export { } from './components/Header/definition'; export { LinkDefinition } from './components/Link/definition'; export { - ListBoxDefinition, - ListBoxItemDefinition, -} from './components/ListBox/definition'; + ListDefinition, + ListRowDefinition, +} from './components/List/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 3996fe4083..c90e5c5c7b 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -53,7 +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/List'; export * from './components/Select'; export * from './components/Skeleton'; export * from './components/Switch'; diff --git a/packages/ui/src/recipes/CardsWithListBox.stories.tsx b/packages/ui/src/recipes/CardsWithList.stories.tsx similarity index 97% rename from packages/ui/src/recipes/CardsWithListBox.stories.tsx rename to packages/ui/src/recipes/CardsWithList.stories.tsx index 3a41dfb346..21113d99a4 100644 --- a/packages/ui/src/recipes/CardsWithListBox.stories.tsx +++ b/packages/ui/src/recipes/CardsWithList.stories.tsx @@ -28,8 +28,8 @@ import { MenuItem, TagGroup, Tag, - ListBox, - ListBoxItem, + List, + ListRow, } from '..'; import { RiAccountCircleLine, @@ -162,9 +162,9 @@ const ServiceListCard = ({ - + {items.map(item => ( - {item.label} - + ))} - + ); @@ -201,7 +201,7 @@ const withRouter = (Story: StoryFn) => ( ); const meta = preview.meta({ - title: 'Recipes/Cards with ListBox', + title: 'Recipes/Cards with List', parameters: { layout: 'fullscreen', }, From 5fd81db713f7f0ce987bd2cdda5a00095ec495c0 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 16 Mar 2026 12:10:32 +0000 Subject: [PATCH 12/14] Update types.ts Signed-off-by: Charles de Dreuille --- docs-ui/src/utils/types.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs-ui/src/utils/types.ts b/docs-ui/src/utils/types.ts index c2c383796f..0c2b59956e 100644 --- a/docs-ui/src/utils/types.ts +++ b/docs-ui/src/utils/types.ts @@ -19,6 +19,8 @@ export type Component = | 'heading' | 'icon' | 'link' + | 'list' + | 'list-row' | 'menu' | 'password-field' | 'radio-group' From b0a6ee2a8afb16ca36325918f132a527c938d4c2 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 16 Mar 2026 13:31:11 +0000 Subject: [PATCH 13/14] Update page.mdx Signed-off-by: Charles de Dreuille --- docs-ui/src/app/components/list/page.mdx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/docs-ui/src/app/components/list/page.mdx b/docs-ui/src/app/components/list/page.mdx index 5ec90146cb..dadeb9c1ee 100644 --- a/docs-ui/src/app/components/list/page.mdx +++ b/docs-ui/src/app/components/list/page.mdx @@ -22,10 +22,7 @@ import { } from './snippets'; import { PageTitle } from '@/components/PageTitle'; import { Theming } from '@/components/Theming'; -import { - ListDefinition, - ListRowDefinition, -} from '../../../utils/definitions'; +import { ListDefinition, ListRowDefinition } from '../../../utils/definitions'; import { ChangelogComponent } from '@/components/ChangelogComponent'; export const reactAriaUrls = { From 2f581de6ba135fa536fff416746f89849e8fd6e7 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Mon, 16 Mar 2026 14:51:49 +0000 Subject: [PATCH 14/14] Fixed keyboard navigation Signed-off-by: Charles de Dreuille --- .changeset/fix-focus-visible-data-attr.md | 7 +++++++ .../ui/src/components/Accordion/Accordion.module.css | 2 +- packages/ui/src/components/Button/Button.module.css | 12 ++++++------ .../src/components/ButtonIcon/ButtonIcon.module.css | 6 +++--- .../src/components/ButtonLink/ButtonLink.module.css | 6 +++--- packages/ui/src/components/Card/Card.module.css | 2 +- packages/ui/src/components/List/List.module.css | 7 +++++-- packages/ui/src/components/Menu/Menu.module.css | 2 +- packages/ui/src/components/Select/Select.module.css | 2 +- .../ToggleButtonGroup/ToggleButtonGroup.module.css | 2 +- 10 files changed, 29 insertions(+), 19 deletions(-) create mode 100644 .changeset/fix-focus-visible-data-attr.md diff --git a/.changeset/fix-focus-visible-data-attr.md b/.changeset/fix-focus-visible-data-attr.md new file mode 100644 index 0000000000..5e4f192d4f --- /dev/null +++ b/.changeset/fix-focus-visible-data-attr.md @@ -0,0 +1,7 @@ +--- +'@backstage/ui': patch +--- + +Fixed focus ring styles to use React Aria's `[data-focus-visible]` data attribute instead of the native CSS `:focus-visible` pseudo-class. This ensures keyboard focus rings render reliably when focus is managed programmatically by React Aria (e.g. inside a GridList, Menu, or Select). + +**Affected components:** Accordion, Button, ButtonIcon, ButtonLink, Card, List, Menu, Select, ToggleButtonGroup diff --git a/packages/ui/src/components/Accordion/Accordion.module.css b/packages/ui/src/components/Accordion/Accordion.module.css index 23b527c2e7..83ae6c24b8 100644 --- a/packages/ui/src/components/Accordion/Accordion.module.css +++ b/packages/ui/src/components/Accordion/Accordion.module.css @@ -53,7 +53,7 @@ cursor: pointer; text-align: left; - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); diff --git a/packages/ui/src/components/Button/Button.module.css b/packages/ui/src/components/Button/Button.module.css index 322a65279d..8273e77d5a 100644 --- a/packages/ui/src/components/Button/Button.module.css +++ b/packages/ui/src/components/Button/Button.module.css @@ -73,7 +73,7 @@ --fg: var(--bui-fg-solid-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: 2px solid var(--bui-ring); outline-offset: 2px; } @@ -106,7 +106,7 @@ --bg-active: var(--bg-solid-danger-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: 2px solid var(--bui-border-danger); outline-offset: 2px; } @@ -143,7 +143,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); @@ -172,7 +172,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { box-shadow: inset 0 0 0 2px var(--bui-border-danger); } } @@ -204,7 +204,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); @@ -232,7 +232,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { box-shadow: inset 0 0 0 2px var(--bui-border-danger); } } diff --git a/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css b/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css index 128e958594..657b143ed5 100644 --- a/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css +++ b/packages/ui/src/components/ButtonIcon/ButtonIcon.module.css @@ -73,7 +73,7 @@ --fg: var(--bui-fg-solid-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: 2px solid var(--bui-ring); outline-offset: 2px; } @@ -110,7 +110,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); @@ -144,7 +144,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); diff --git a/packages/ui/src/components/ButtonLink/ButtonLink.module.css b/packages/ui/src/components/ButtonLink/ButtonLink.module.css index 74a3fe6ba6..3c221ab878 100644 --- a/packages/ui/src/components/ButtonLink/ButtonLink.module.css +++ b/packages/ui/src/components/ButtonLink/ButtonLink.module.css @@ -67,7 +67,7 @@ --fg: var(--bui-fg-solid-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: 2px solid var(--bui-ring); outline-offset: 2px; } @@ -103,7 +103,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); @@ -136,7 +136,7 @@ --fg: var(--bui-fg-disabled); } - &:focus-visible { + &[data-focus-visible] { outline: none; transition: none; box-shadow: inset 0 0 0 2px var(--bui-ring); diff --git a/packages/ui/src/components/Card/Card.module.css b/packages/ui/src/components/Card/Card.module.css index 231c6c55e0..eced185550 100644 --- a/packages/ui/src/components/Card/Card.module.css +++ b/packages/ui/src/components/Card/Card.module.css @@ -69,7 +69,7 @@ } } - .bui-Card[data-interactive]:has(.bui-CardTrigger:focus-visible) { + .bui-Card[data-interactive]:has(.bui-CardTrigger[data-focus-visible]) { outline: 2px solid var(--bui-ring); outline-offset: -2px; } diff --git a/packages/ui/src/components/List/List.module.css b/packages/ui/src/components/List/List.module.css index f41b086618..9968f0b5bc 100644 --- a/packages/ui/src/components/List/List.module.css +++ b/packages/ui/src/components/List/List.module.css @@ -19,7 +19,6 @@ @layer components { .bui-List { box-sizing: border-box; - overflow-y: auto; outline: none; display: flex; flex-direction: column; @@ -30,7 +29,7 @@ gap: 0; } - &:focus-visible { + &[data-focus-visible] { outline: none; } } @@ -51,6 +50,10 @@ color: var(--bui-fg-disabled); } + &[data-focus-visible] { + outline: 2px solid var(--bui-ring); + } + &[data-selection-mode] { cursor: pointer; padding-block: var(--bui-space-2); diff --git a/packages/ui/src/components/Menu/Menu.module.css b/packages/ui/src/components/Menu/Menu.module.css index e16f87ee5a..9acedd106e 100644 --- a/packages/ui/src/components/Menu/Menu.module.css +++ b/packages/ui/src/components/Menu/Menu.module.css @@ -78,7 +78,7 @@ padding-inline: var(--bui-space-1); display: block; - &:focus-visible { + &[data-focus-visible] { outline: none; } diff --git a/packages/ui/src/components/Select/Select.module.css b/packages/ui/src/components/Select/Select.module.css index 74801de1a9..c5ce30e363 100644 --- a/packages/ui/src/components/Select/Select.module.css +++ b/packages/ui/src/components/Select/Select.module.css @@ -135,7 +135,7 @@ padding-block: var(--bui-space-1); padding-inline: var(--bui-space-1); - &:focus-visible { + &[data-focus-visible] { /* Remove default focus-visible outline because React Aria * triggers it on mouse click open of the list for some reason. * On keyboard use, the top item receives the focus style, diff --git a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css index fef9b33a9a..827b7f18d6 100644 --- a/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css +++ b/packages/ui/src/components/ToggleButtonGroup/ToggleButtonGroup.module.css @@ -102,7 +102,7 @@ } /* Focus ring on group surface */ - .bui-ToggleButtonGroup:focus-visible { + .bui-ToggleButtonGroup[data-focus-visible] { outline: 2px solid var(--bui-ring); outline-offset: 2px; }