From 2b1bb9ca3e9a62a10527d2ed8b2971017485b512 Mon Sep 17 00:00:00 2001 From: Charles de Dreuille Date: Sat, 14 Mar 2026 18:17:03 +0000 Subject: [PATCH] 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; };