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 <charles.dedreuille@gmail.com>
Made-with: Cursor
This commit is contained in:
Charles de Dreuille
2026-03-14 18:17:03 +00:00
parent b7b1b86d8a
commit 2b1bb9ca3e
5 changed files with 117 additions and 4 deletions
@@ -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;
}
}
@@ -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 => (
<MemoryRouter>
<Story />
</MemoryRouter>
),
],
});
const items = [
@@ -178,3 +193,71 @@ export const Disabled = meta.story({
</ListBox>
),
});
export const WithActionsMenu = meta.story({
render: args => (
<ListBox {...args}>
{items.map(item => (
<ListBoxItem
key={item.id}
id={item.id}
icon={itemIcons[item.id]}
customActions={
<MenuTrigger>
<ButtonIcon
icon={<RiMoreLine />}
size="small"
aria-label="More actions"
/>
<Menu>
<MenuItem iconStart={<RiEdit2Line />}>Edit</MenuItem>
<MenuItem iconStart={<RiShareBoxLine />}>Share</MenuItem>
<MenuItem iconStart={<RiDeleteBinLine />} color="danger">
Delete
</MenuItem>
</Menu>
</MenuTrigger>
}
>
{item.label}
</ListBoxItem>
))}
</ListBox>
),
});
export const WithActionsTags = meta.story({
args: {
style: { width: 380 },
},
render: args => {
const tagMap: Record<string, string[]> = {
react: ['frontend', 'ui'],
typescript: ['typed', 'js'],
javascript: ['web'],
rust: ['systems', 'fast'],
go: ['backend'],
};
return (
<ListBox {...args}>
{items.map(item => (
<ListBoxItem
key={item.id}
id={item.id}
icon={itemIcons[item.id]}
customActions={
<TagGroup aria-label={`Tags for ${item.label}`}>
{tagMap[item.id].map(tag => (
<Tag key={tag}>{tag}</Tag>
))}
</TagGroup>
}
>
{item.label}
</ListBoxItem>
))}
</ListBox>
);
},
});
+16 -2
View File
@@ -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 = <T extends object>(props: ListBoxProps<T>) => {
*/
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) => {
<RiCheckLine />
</div>
)}
{icon && <div className={classes.icon}>{icon}</div>}
{icon && (
<Box bg="neutral" className={classes.icon}>
{icon}
</Box>
)}
<div className={classes.label}>
<Text slot="label">{children}</Text>
{description && (
@@ -78,6 +83,15 @@ export const ListBoxItem = (props: ListBoxItemProps) => {
</Text>
)}
</div>
{customActions && (
<div
className={classes.actions}
onClick={e => e.stopPropagation()}
onKeyDown={e => e.stopPropagation()}
>
{customActions}
</div>
)}
</>
)}
</RAListBoxItem>
@@ -47,11 +47,13 @@ export const ListBoxItemDefinition = defineComponent<ListBoxItemOwnProps>()({
icon: 'bui-ListBoxItemIcon',
label: 'bui-ListBoxItemLabel',
description: 'bui-ListBoxItemDescription',
actions: 'bui-ListBoxItemActions',
},
propDefs: {
children: {},
description: {},
icon: {},
customActions: {},
className: {},
},
});
+6 -1
View File
@@ -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;
};