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:
@@ -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>
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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: {},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user