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', },