diff --git a/.changeset/slimy-islands-play.md b/.changeset/slimy-islands-play.md
new file mode 100644
index 0000000000..497d86761b
--- /dev/null
+++ b/.changeset/slimy-islands-play.md
@@ -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
diff --git a/docs-ui/src/content/table.mdx b/docs-ui/src/content/table.mdx
index d1a190e9dd..00b7290532 100644
--- a/docs-ui/src/content/table.mdx
+++ b/docs-ui/src/content/table.mdx
@@ -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.
+
+}
+ 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.
+
+}
+ 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.
+
+}
+ code={tableSelectionActionsSnippet}
+/>
### Row Clicks
diff --git a/docs-ui/src/content/table.props.ts b/docs-ui/src/content/table.props.ts
index 443526182e..dfd3d12988 100644
--- a/docs-ui/src/content/table.props.ts
+++ b/docs-ui/src/content/table.props.ts
@@ -326,3 +326,52 @@ const { data: paginatedData, paginationProps } = useTable({
`;
+
+export const tableSelectionActionsSnippet = `import { Table, TableHeader, TableBody, Column, Row, Cell } from '@backstage/ui';
+
+function MyTable() {
+ const [selectedKeys, setSelectedKeys] = React.useState(new Set([]));
+
+ return (
+
console.log('Opening', key)}
+ >
+
+ Name
+ Status
+
+
+
+ |
+ |
+
+
+ |
+ |
+
+
+
+ );
+}`;
+
+export const tableSelectionModeSnippet = ``;
+
+export const tableSelectionBehaviorSnippet = ``;
diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md
index 30910724eb..2308142d77 100644
--- a/packages/ui/report.api.md
+++ b/packages/ui/report.api.md
@@ -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';
};
};
diff --git a/packages/ui/src/components/Table/Table.module.css b/packages/ui/src/components/Table/Table.module.css
index 3dd407f0e8..6f720f85b2 100644
--- a/packages/ui/src/components/Table/Table.module.css
+++ b/packages/ui/src/components/Table/Table.module.css
@@ -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 {
diff --git a/packages/ui/src/components/Table/Table.stories.tsx b/packages/ui/src/components/Table/Table.stories.tsx
index cee7169b62..547dbef7bf 100644
--- a/packages/ui/src/components/Table/Table.stories.tsx
+++ b/packages/ui/src/components/Table/Table.stories.tsx
@@ -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(new Set([]));
+
+ return (
+
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+ );
+ },
+};
+
+export const SelectionMultiToggle: Story = {
+ render: () => {
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ return (
+
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+ );
+ },
+};
+
+export const SelectionSingleReplace: Story = {
+ render: () => {
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ return (
+
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+ );
+ },
+};
+
+export const SelectionMultiReplace: Story = {
+ render: () => {
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ return (
+
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+ );
+ },
+};
+
+export const SelectionToggleWithActions: Story = {
+ render: () => {
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ return (
+ alert(`Opening ${key}`)}
+ >
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+ );
+ },
+};
+
+export const SelectionReplaceWithActions: Story = {
+ render: () => {
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ return (
+ alert(`Opening ${key}`)}
+ >
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+ );
+ },
+};
+
+export const SelectionToggleWithLinks: Story = {
+ render: () => {
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ return (
+
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+ );
+ },
+};
+
+export const SelectionReplaceWithLinks: Story = {
+ render: () => {
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ return (
+
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+ );
+ },
+};
+
+export const SelectionWithDisabledRows: Story = {
+ render: () => {
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ return (
+
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+ );
+ },
+};
+
+export const SelectionWithPagination: Story = {
+ render: () => {
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ const { data, paginationProps } = useTable({
+ data: data1,
+ pagination: {
+ defaultPageSize: 5,
+ },
+ });
+
+ return (
+ <>
+
+
+ Name
+ Owner
+ Type
+
+
+ {data?.map(item => (
+
+ |
+ |
+ |
+
+ ))}
+
+
+
+ >
+ );
+ },
+};
+
+export const SelectionModePlayground: Story = {
+ render: () => {
+ const [selectionMode, setSelectionMode] = useState<'single' | 'multiple'>(
+ 'multiple',
+ );
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ return (
+
+
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+
+
+ Selection mode:
+
+ {
+ setSelectionMode(value as 'single' | 'multiple');
+ setSelectedKeys(new Set([]));
+ }}
+ >
+ single
+ multiple
+
+
+
+ );
+ },
+};
+
+export const SelectionBehaviorPlayground: Story = {
+ render: () => {
+ const [selectionBehavior, setSelectionBehavior] = useState<
+ 'toggle' | 'replace'
+ >('toggle');
+ const [selectedKeys, setSelectedKeys] = useState(new Set([]));
+
+ return (
+
+
+
+ Name
+ Owner
+ Type
+
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+ |
+ |
+ |
+
+
+
+
+
+ Selection behavior:
+
+ {
+ setSelectionBehavior(value as 'toggle' | 'replace');
+ setSelectedKeys(new Set([]));
+ }}
+ >
+ toggle
+ replace
+
+
+
+ );
+ },
+};
diff --git a/packages/ui/src/components/Table/components/Column.tsx b/packages/ui/src/components/Table/components/Column.tsx
index 78c45f6a3c..9076ec8557 100644
--- a/packages/ui/src/components/Table/components/Column.tsx
+++ b/packages/ui/src/components/Table/components/Column.tsx
@@ -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 (
{({ allowsSorting, sortDirection }) => (
diff --git a/packages/ui/src/components/Table/components/Row.tsx b/packages/ui/src/components/Table/components/Row.tsx
index 0b09c157ab..e08649c63b 100644
--- a/packages/ui/src/components/Table/components/Row.tsx
+++ b/packages/ui/src/components/Table/components/Row.tsx
@@ -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(props: RowProps) {
@@ -38,14 +39,23 @@ export function Row(props: RowProps) {
const navigate = useNavigate();
const isExternal = isExternalLink(href);
- let { selectionBehavior } = useTableOptions();
+ let { selectionBehavior, selectionMode } = useTableOptions();
const content = (
<>
- {selectionBehavior === 'toggle' && (
-
-
- |
+ {selectionBehavior === 'toggle' && selectionMode === 'multiple' && (
+
+
+
+ <>>
+
+
+
)}
{children}
>
diff --git a/packages/ui/src/components/Table/components/TableHeader.tsx b/packages/ui/src/components/Table/components/TableHeader.tsx
index cf4fb80ec9..28146b098a 100644
--- a/packages/ui/src/components/Table/components/TableHeader.tsx
+++ b/packages/ui/src/components/Table/components/TableHeader.tsx
@@ -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 = (props: TableHeaderProps) => {
@@ -38,9 +40,21 @@ export const TableHeader = (props: TableHeaderProps) => {
className={clsx(classNames.header, styles[classNames.header])}
{...rest}
>
- {selectionBehavior === 'toggle' && (
-
- {selectionMode === 'multiple' && }
+ {selectionBehavior === 'toggle' && selectionMode === 'multiple' && (
+
+
+
+ <>>
+
+
)}
{children}
diff --git a/packages/ui/src/components/Table/definition.ts b/packages/ui/src/components/Table/definition.ts
index 8ec9dfb1f0..313604902c 100644
--- a/packages/ui/src/components/Table/definition.ts
+++ b/packages/ui/src/components/Table/definition.ts
@@ -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;