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;