feat(ui): add Table row selection support with visual states and documentation

- Add row state styling for hover, selected, pressed, and disabled states
- Fix checkbox rendering to only appear for multi-select toggle mode
- Add fixed 40px width for selection column header and cells
- Fix Column component to properly merge className prop
- Switch from React Aria headless Checkbox to styled Backstage UI Checkbox
- Add 12 selection Storybook stories covering all mode/behavior combinations
- Add interactive playground stories for selection mode and behavior
- Document row selection in docs-ui with examples

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2025-11-27 11:20:59 +01:00
parent 61161c98fe
commit a20d317255
10 changed files with 662 additions and 23 deletions
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added row selection support with visual state styling for hover, selected, and pressed states. Fixed checkbox rendering to only show for multi-select toggle mode.
Affected components: Table, TableHeader, Row, Column
+33 -2
View File
@@ -13,7 +13,9 @@ import {
tableHybridSnippet,
tableCellInteractionsSnippet,
tablePaginationSnippet,
tableSelectionSnippet,
tableSelectionActionsSnippet,
tableSelectionModeSnippet,
tableSelectionBehaviorSnippet,
tableSortingSnippet,
columnPropDefs,
rowPropDefs,
@@ -91,7 +93,36 @@ Coming soon.
### Row selection
Coming soon.
Tables support row selection with two configuration options: selection mode and selection behavior.
#### Selection mode
Use `selectionMode` to control how many rows can be selected. With `single`, only one row can be selected at a time. With `multiple`, any number of rows can be selected, and a header checkbox provides select-all functionality.
<Snippet
preview={<TableSnippet story="SelectionModePlayground" />}
code={tableSelectionModeSnippet}
/>
#### Selection behavior
Use `selectionBehavior` to control how selection is indicated and interacted with. With `toggle`, checkboxes appear for selection. With `replace`, selection is indicated by row background color—click to select, Cmd/Ctrl+click for multiple.
<Snippet
preview={<TableSnippet story="SelectionBehaviorPlayground" />}
code={tableSelectionBehaviorSnippet}
/>
#### With row actions
With toggle behavior, clicking a row triggers its action when nothing is selected. Once any row is selected, clicking toggles selection instead.
With replace behavior, clicking selects the row and double-clicking triggers the action.
<Snippet
preview={<TableSnippet story="SelectionToggleWithActions" />}
code={tableSelectionActionsSnippet}
/>
### Row Clicks
+49
View File
@@ -326,3 +326,52 @@ const { data: paginatedData, paginationProps } = useTable({
</TableBody>
</Table>
<TablePagination {...paginationProps} />`;
export const tableSelectionActionsSnippet = `import { Table, TableHeader, TableBody, Column, Row, Cell } from '@backstage/ui';
function MyTable() {
const [selectedKeys, setSelectedKeys] = React.useState(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
onRowAction={(key) => console.log('Opening', key)}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Status</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell title="Component A" />
<Cell title="Active" />
</Row>
<Row id="2">
<Cell title="Component B" />
<Cell title="Inactive" />
</Row>
</TableBody>
</Table>
);
}`;
export const tableSelectionModeSnippet = `<Table
selectionMode="multiple" // or "single"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
{/* ... */}
</Table>`;
export const tableSelectionBehaviorSnippet = `<Table
selectionMode="multiple"
selectionBehavior="toggle" // or "replace"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
{/* ... */}
</Table>`;
+2
View File
@@ -1265,6 +1265,8 @@ export const TableDefinition: {
readonly cellProfileAvatarFallback: 'bui-TableCellProfileAvatarFallback';
readonly cellProfileName: 'bui-TableCellProfileName';
readonly cellProfileLink: 'bui-TableCellProfileLink';
readonly headSelection: 'bui-TableHeadSelection';
readonly cellSelection: 'bui-TableCellSelection';
};
};
@@ -41,6 +41,10 @@
}
}
.bui-TableHeadSelection {
width: 40px;
}
.bui-TableHeadContent {
display: flex;
flex-direction: row;
@@ -81,23 +85,34 @@
border-bottom: 1px solid var(--bui-border);
transition: color 0.2s ease-in-out;
&:hover {
background-color: var(--bui-bg-tint-hover);
}
&[data-selected] {
background-color: var(--bui-bg-tint-pressed);
}
&[data-pressed] {
background-color: var(--bui-bg-tint-pressed);
}
&[data-disabled] {
background-color: var(--bui-bg-tint-disabled);
}
&[data-react-aria-pressable='true'] {
cursor: pointer;
}
}
.bui-TableBody .bui-TableRow:hover {
background-color: var(--bui-gray-2);
}
.bui-TableCell {
padding: var(--bui-space-3);
font-size: var(--bui-font-size-3);
}
.bui-TableCell {
padding: var(--bui-space-3);
font-size: var(--bui-font-size-3);
.bui-TableCellSelection {
width: 40px;
}
.bui-TableCellContentWrapper {
@@ -16,6 +16,7 @@
import { useState } from 'react';
import type { Meta, StoryFn, StoryObj } from '@storybook/react-vite';
import { type Selection } from 'react-aria-components';
import {
Table,
TableHeader,
@@ -26,6 +27,8 @@ import {
CellProfile as CellProfileBUI,
useTable,
} from '.';
import { RadioGroup, Radio } from '../RadioGroup';
import { Flex } from '../Flex';
import { MemoryRouter } from 'react-router-dom';
import { data as data1Raw } from './mocked-data1';
import { data as data2 } from './mocked-data2';
@@ -33,6 +36,7 @@ import { data as data3 } from './mocked-data3';
import { data as data4 } from './mocked-data4';
import { RiCactusLine } from '@remixicon/react';
import { TablePagination } from '../TablePagination';
import { Text } from '../Text';
const meta = {
title: 'Backstage UI/Table',
@@ -367,3 +371,508 @@ export const CellProfile: Story = {
);
},
};
export const SelectionSingleToggle: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="single"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2">
<Cell title="API Gateway" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
);
},
};
export const SelectionMultiToggle: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2">
<Cell title="API Gateway" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
);
},
};
export const SelectionSingleReplace: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="single"
selectionBehavior="replace"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2">
<Cell title="API Gateway" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
);
},
};
export const SelectionMultiReplace: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="replace"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2">
<Cell title="API Gateway" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
);
},
};
export const SelectionToggleWithActions: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
onRowAction={key => alert(`Opening ${key}`)}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2">
<Cell title="API Gateway" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
);
},
};
export const SelectionReplaceWithActions: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="replace"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
onRowAction={key => alert(`Opening ${key}`)}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2">
<Cell title="API Gateway" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
);
},
};
export const SelectionToggleWithLinks: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1" href="https://example.com/library">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2" href="https://example.com/gateway">
<Cell title="API Gateway" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3" href="https://example.com/docs">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
);
},
};
export const SelectionReplaceWithLinks: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="replace"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1" href="https://example.com/library">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2" href="https://example.com/gateway">
<Cell title="API Gateway" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3" href="https://example.com/docs">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
);
},
};
export const SelectionWithDisabledRows: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
disabledKeys={['2']}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2">
<Cell title="API Gateway (Disabled)" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
);
},
};
export const SelectionWithPagination: Story = {
render: () => {
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
const { data, paginationProps } = useTable({
data: data1,
pagination: {
defaultPageSize: 5,
},
});
return (
<>
<Table
selectionMode="multiple"
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
{data?.map(item => (
<Row key={item.name} id={item.name}>
<Cell title={item.name} />
<Cell title={item.owner.name} />
<Cell title={item.type} />
</Row>
))}
</TableBody>
</Table>
<TablePagination {...paginationProps} />
</>
);
},
};
export const SelectionModePlayground: Story = {
render: () => {
const [selectionMode, setSelectionMode] = useState<'single' | 'multiple'>(
'multiple',
);
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Flex direction="column" gap="8">
<Table
selectionMode={selectionMode}
selectionBehavior="toggle"
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2">
<Cell title="API Gateway" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
<div>
<Text as="h4" style={{ marginBottom: 'var(--bui-space-2)' }}>
Selection mode:
</Text>
<RadioGroup
aria-label="Selection mode"
orientation="horizontal"
value={selectionMode}
onChange={value => {
setSelectionMode(value as 'single' | 'multiple');
setSelectedKeys(new Set([]));
}}
>
<Radio value="single">single</Radio>
<Radio value="multiple">multiple</Radio>
</RadioGroup>
</div>
</Flex>
);
},
};
export const SelectionBehaviorPlayground: Story = {
render: () => {
const [selectionBehavior, setSelectionBehavior] = useState<
'toggle' | 'replace'
>('toggle');
const [selectedKeys, setSelectedKeys] = useState<Selection>(new Set([]));
return (
<Flex direction="column" gap="8">
<Table
selectionMode="multiple"
selectionBehavior={selectionBehavior}
selectedKeys={selectedKeys}
onSelectionChange={setSelectedKeys}
>
<TableHeader>
<Column isRowHeader>Name</Column>
<Column>Owner</Column>
<Column>Type</Column>
</TableHeader>
<TableBody>
<Row id="1">
<Cell title="Component Library" />
<Cell title="Design System" />
<Cell title="library" />
</Row>
<Row id="2">
<Cell title="API Gateway" />
<Cell title="Platform" />
<Cell title="service" />
</Row>
<Row id="3">
<Cell title="Documentation Site" />
<Cell title="DevEx" />
<Cell title="website" />
</Row>
</TableBody>
</Table>
<div>
<Text as="h4" style={{ marginBottom: 'var(--bui-space-2)' }}>
Selection behavior:
</Text>
<RadioGroup
aria-label="Selection behavior"
orientation="horizontal"
value={selectionBehavior}
onChange={value => {
setSelectionBehavior(value as 'toggle' | 'replace');
setSelectedKeys(new Set([]));
}}
>
<Radio value="toggle">toggle</Radio>
<Radio value="replace">replace</Radio>
</RadioGroup>
</div>
</Flex>
);
},
};
@@ -25,11 +25,11 @@ import { RiArrowUpLine, RiArrowDownLine } from '@remixicon/react';
/** @public */
export const Column = (props: ColumnProps) => {
const { classNames, cleanedProps } = useStyles(TableDefinition, props);
const { children, ...rest } = cleanedProps;
const { className, children, ...rest } = cleanedProps;
return (
<ReactAriaColumn
className={clsx(classNames.head, styles[classNames.head])}
className={clsx(classNames.head, styles[classNames.head], className)}
{...rest}
>
{({ allowsSorting, sortDirection }) => (
@@ -18,11 +18,11 @@ import {
Row as ReactAriaRow,
RowProps,
useTableOptions,
Cell,
Cell as ReactAriaCell,
Collection,
Checkbox,
RouterProvider,
} from 'react-aria-components';
import { Checkbox } from '../../Checkbox';
import { useStyles } from '../../../hooks/useStyles';
import { TableDefinition } from '../definition';
import { useNavigate } from 'react-router-dom';
@@ -30,6 +30,7 @@ import { useHref } from 'react-router-dom';
import { isExternalLink } from '../../../utils/isExternalLink';
import styles from '../Table.module.css';
import clsx from 'clsx';
import { Flex } from '../../Flex';
/** @public */
export function Row<T extends object>(props: RowProps<T>) {
@@ -38,14 +39,23 @@ export function Row<T extends object>(props: RowProps<T>) {
const navigate = useNavigate();
const isExternal = isExternalLink(href);
let { selectionBehavior } = useTableOptions();
let { selectionBehavior, selectionMode } = useTableOptions();
const content = (
<>
{selectionBehavior === 'toggle' && (
<Cell>
<Checkbox slot="selection" />
</Cell>
{selectionBehavior === 'toggle' && selectionMode === 'multiple' && (
<ReactAriaCell
className={clsx(
classNames.cellSelection,
styles[classNames.cellSelection],
)}
>
<Flex justify="center" align="center">
<Checkbox slot="selection">
<></>
</Checkbox>
</Flex>
</ReactAriaCell>
)}
<Collection items={columns}>{children}</Collection>
</>
@@ -17,14 +17,16 @@
import {
TableHeader as ReactAriaTableHeader,
type TableHeaderProps,
Checkbox,
Collection,
useTableOptions,
} from 'react-aria-components';
import { Collection, useTableOptions } from 'react-aria-components';
import { Checkbox } from '../../Checkbox';
import { Column } from './Column';
import { useStyles } from '../../../hooks/useStyles';
import { TableDefinition } from '../definition';
import styles from '../Table.module.css';
import clsx from 'clsx';
import { Flex } from '../../Flex';
/** @public */
export const TableHeader = <T extends object>(props: TableHeaderProps<T>) => {
@@ -38,9 +40,21 @@ export const TableHeader = <T extends object>(props: TableHeaderProps<T>) => {
className={clsx(classNames.header, styles[classNames.header])}
{...rest}
>
{selectionBehavior === 'toggle' && (
<Column>
{selectionMode === 'multiple' && <Checkbox slot="selection" />}
{selectionBehavior === 'toggle' && selectionMode === 'multiple' && (
<Column
width={40}
minWidth={40}
maxWidth={40}
className={clsx(
classNames.headSelection,
styles[classNames.headSelection],
)}
>
<Flex justify="center" align="center">
<Checkbox slot="selection">
<></>
</Checkbox>
</Flex>
</Column>
)}
<Collection items={columns}>{children}</Collection>
@@ -39,5 +39,7 @@ export const TableDefinition = {
cellProfileAvatarFallback: 'bui-TableCellProfileAvatarFallback',
cellProfileName: 'bui-TableCellProfileName',
cellProfileLink: 'bui-TableCellProfileLink',
headSelection: 'bui-TableHeadSelection',
cellSelection: 'bui-TableCellSelection',
},
} as const satisfies ComponentDefinition;