Support row click

Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
Charles de Dreuille
2025-08-02 10:38:43 +01:00
parent f65fb597b7
commit 98c0959729
17 changed files with 490 additions and 202 deletions
File diff suppressed because one or more lines are too long
+65 -2
View File
@@ -4,6 +4,8 @@ import { CodeBlock } from '@/components/CodeBlock';
import { TableSnippet } from '@/snippets/stories-snippets';
import {
tablePropDefs,
tableHeaderPropDefs,
tableBodyPropDefs,
tablePaginationPropDefs,
tableUsageSnippet,
tableBasicSnippet,
@@ -13,6 +15,9 @@ import {
tablePaginationSnippet,
tableSelectionSnippet,
tableSortingSnippet,
columnPropDefs,
rowPropDefs,
cellPropDefs,
} from './table.props';
import { ChangelogComponent } from '@/components/ChangelogComponent';
import { PageTitle } from '@/components/PageTitle';
@@ -39,13 +44,43 @@ import { Theming } from '@/components/Theming';
The main table component that renders data in a structured grid format.
Coming soon.
<PropsTable data={tablePropDefs} />
### TableHeader
The header section of the table that contains the column definitions.
<PropsTable data={tableHeaderPropDefs} />
### Column
A column definition that describes how a column should be rendered.
<PropsTable data={columnPropDefs} />
### TableBody
The body section of the table that contains the rows.
<PropsTable data={tableBodyPropDefs} />
### Row
A row definition that describes how a row should be rendered.
<PropsTable data={rowPropDefs} />
### Cell
A cell definition that describes how a cell should be rendered.
<PropsTable data={cellPropDefs} />
### TablePagination
A pagination component designed to work with the Table component.
Coming soon.
<PropsTable data={tablePaginationPropDefs} />
## Examples
@@ -53,6 +88,10 @@ Coming soon.
Coming soon.
### Row selection
Coming soon.
### Row Clicks
Coming soon.
@@ -65,6 +104,30 @@ Coming soon.
Coming soon.
### Asynchronous loading
Coming soon.
### Empty state
Coming soon.
### Column resizing
This feature is not available yet — let us know if you'd like us to explore it!
### Column reordering
This feature is not available yet — let us know if you'd like us to explore it!
### Column pinning
This feature is not available yet — let us know if you'd like us to explore it!
### Column visibility
This feature is not available yet — let us know if you'd like us to explore it!
<Theming component="Table" />
<ChangelogComponent component="table" />
+248 -56
View File
@@ -5,72 +5,264 @@ import {
} from '../../utils/propDefs';
export const tablePropDefs: Record<string, PropDef> = {
table: {
type: 'complex',
complexType: {
name: 'Table<TData>',
properties: {
'TanStack Table instance': {
type: 'object',
required: true,
description:
'The TanStack Table instance created with useReactTable()',
},
},
},
required: true,
selectionBehavior: {
type: 'enum',
values: ['toggle', 'replace'],
default: 'toggle',
description: 'How multiple selection should behave in the collection.',
},
onRowClick: {
type: 'complex',
complexType: {
name: '(row: TData, event: React.MouseEvent<HTMLTableRowElement>) => void',
properties: {
row: {
type: 'TData',
required: true,
description: 'The row data object',
},
event: {
type: 'React.MouseEvent<HTMLTableRowElement>',
required: true,
description: 'The mouse event object',
},
},
},
required: false,
disabledBehavior: {
type: 'enum',
values: ['selection', 'all'],
default: 'selection',
description:
'Whether disabledKeys applies to all interactions, or only selection.',
},
disabledKeys: {
type: 'enum',
values: ['Iterable<Key>'],
description: 'A list of row keys to disable.',
},
selectionMode: {
type: 'enum',
values: ['single', 'multiple'],
description: 'The type of selection that is allowed in the collection.',
},
selectedKeys: {
type: 'enum',
values: ['all', 'Iterable<Key>'],
description: 'The currently selected keys in the collection (controlled).',
},
defaultSelectedKeys: {
type: 'enum',
values: ['all', 'Iterable<Key>'],
description: 'The initial selected keys in the collection (uncontrolled).',
},
onRowAction: {
type: 'enum',
values: ['(key: Key) => void'],
description:
'Handler that is called when a user performs an action on the row.',
},
onSelectionChange: {
type: 'enum',
values: ['(keys: Selection) => void'],
description: 'Handler that is called when the selection changes.',
},
onSortChange: {
type: 'enum',
values: ['(descriptor: SortDescriptor) => any'],
description:
'Handler that is called when the sorted column or direction changes.',
},
...classNamePropDefs,
...stylePropDefs,
};
export const tableHeaderPropDefs: Record<string, PropDef> = {
onHoverStart: {
type: 'enum',
values: ['(e: HoverEvent) => void'],
description: 'Handler that is called when a hover interaction starts.',
},
onHoverEnd: {
type: 'enum',
values: ['(e: HoverEvent) => void'],
description: 'Handler that is called when a hover interaction ends.',
},
onHoverChange: {
type: 'enum',
values: ['(isHovering: boolean) => void'],
description: 'Handler that is called when the hover state changes.',
},
...classNamePropDefs,
...stylePropDefs,
};
export const columnPropDefs: Record<string, PropDef> = {
id: {
type: 'enum',
values: ['Key'],
description: 'The unique id of the column.',
},
allowsSorting: {
type: 'boolean',
description: 'Whether the column allows sorting.',
},
isRowHeader: {
type: 'boolean',
description:
'Whether a column is a row header and should be announced by assistive technology during row navigation.',
},
textValue: {
type: 'string',
description:
"A string representation of the column's contents, used for accessibility announcements.",
},
...classNamePropDefs,
...stylePropDefs,
};
export const tableBodyPropDefs: Record<string, PropDef> = {
renderEmptyState: {
type: 'enum',
values: ['(props) => ReactNode'],
description:
'Provides content to display when there are no rows in the table.',
},
...classNamePropDefs,
...stylePropDefs,
};
export const rowPropDefs: Record<string, PropDef> = {
textValue: {
type: 'string',
description:
"A string representation of the row's contents, used for accessibility announcements.",
},
isDisabled: {
type: 'boolean',
description: 'Whether the row is disabled.',
},
id: {
type: 'enum',
values: ['Key'],
description: 'The unique id of the row.',
},
href: {
type: 'string',
description: 'The URL to navigate to when the row is clicked.',
},
hrefLang: {
type: 'string',
description:
'The language of the URL to navigate to when the row is clicked.',
},
target: {
type: 'string',
description:
'The target of the URL to navigate to when the row is clicked.',
},
rel: {
type: 'string',
description:
'The relationship of the URL to navigate to when the row is clicked.',
},
onAction: {
type: 'enum',
values: ['() => void'],
description:
"Handler that is called when a user performs an action on the row. The exact user event depends on the collection's selectionBehavior prop and the interaction modality.",
},
onHoverStart: {
type: 'enum',
values: ['(e: HoverEvent) => void'],
description: 'Handler that is called when a hover interaction starts.',
},
onHoverEnd: {
type: 'enum',
values: ['(e: HoverEvent) => void'],
description: 'Handler that is called when a hover interaction ends.',
},
onHoverChange: {
type: 'enum',
values: ['(isHovering: boolean) => void'],
description: 'Handler that is called when the hover state changes.',
},
onPress: {
type: 'enum',
values: ['(e: PressEvent) => void'],
description:
'Handler that is called when the press is released over the target.',
},
onPressStart: {
type: 'enum',
values: ['(e: PressEvent) => void'],
description: 'Handler that is called when a press interaction starts.',
},
onPressEnd: {
type: 'enum',
values: ['(e: PressEvent) => void'],
description:
'Handler that is called when a press interaction ends, either over the target or when the pointer leaves the target.',
},
onPressChange: {
type: 'enum',
values: ['(isPressed: boolean) => void'],
description: 'Handler that is called when the press state changes.',
},
onPressUp: {
type: 'enum',
values: ['(e: PressEvent) => void'],
description:
'Handler that is called when a press is released over the target, regardless of whether it started on the target or not.',
},
...classNamePropDefs,
...stylePropDefs,
};
export const cellPropDefs: Record<string, PropDef> = {
id: {
type: 'enum',
values: ['Key'],
description: 'The unique id of the cell.',
},
textValue: {
type: 'string',
description:
"A string representation of the cell's contents, used for features like typeahead.",
},
...classNamePropDefs,
...stylePropDefs,
};
export const tablePaginationPropDefs: Record<string, PropDef> = {
table: {
type: 'complex',
complexType: {
name: 'Table<TData>',
properties: {
'TanStack Table instance': {
type: 'object',
required: true,
description:
'The TanStack Table instance (same instance passed to Table component)',
},
},
},
required: true,
pageIndex: {
type: 'number',
description: 'The current page index.',
},
pageSize: {
type: 'number',
description: 'The number of items per page.',
},
setPageIndex: {
type: 'enum',
values: ['(pageIndex: number) => void'],
description: 'Handler that is called when the page index changes.',
},
setPageSize: {
type: 'enum',
values: ['(pageSize: number) => void'],
description: 'Handler that is called when the page size changes.',
},
rowCount: {
type: 'number',
description: 'The total number of rows in the table.',
},
onNextPage: {
type: 'enum',
values: ['() => void'],
description: 'Handler that is called when the next page is requested.',
},
onPreviousPage: {
type: 'enum',
values: ['() => void'],
description: 'Handler that is called when the previous page is requested.',
},
onPageSizeChange: {
type: 'enum',
values: ['(pageSize: number) => void'],
description: 'Handler that is called when the page size changes.',
},
showPageSizeOptions: {
type: 'boolean',
description: 'Whether to show the page size options.',
},
...classNamePropDefs,
...stylePropDefs,
};
export const tableUsageSnippet = `import {
Cell,
CellProfile,
Column,
Row,
Table,
TableBody,
TableHeader,
TablePagination,
} from '@backstage/ui';
export const tableUsageSnippet = `import { Cell, ..., TableHeader, TablePagination } from '@backstage/ui';
<Table>
<TableHeader>
+1
View File
@@ -28,6 +28,7 @@ export type PropDef = {
default?: string;
required?: boolean;
responsive?: boolean;
description?: string;
};
export { breakpoints };
+4 -13
View File
@@ -9702,19 +9702,6 @@
display: flex;
}
.bui-Hidden {
clip: rect(0 0 0 0) !important;
clip-path: inset(100%) !important;
white-space: nowrap !important;
border: 0 !important;
width: 1px !important;
height: 1px !important;
margin: -1px !important;
padding: 0 !important;
position: absolute !important;
overflow: hidden !important;
}
.bui-Icon {
width: 1rem;
height: 1rem;
@@ -10069,6 +10056,10 @@
.bui-TableRow {
border-bottom: 1px solid var(--bui-border);
transition: color .2s ease-in-out;
&[data-react-aria-pressable="true"] {
cursor: pointer;
}
}
.bui-TableBody .bui-TableRow:hover {
+1 -3
View File
@@ -1050,9 +1050,6 @@ export const heightPropDefs: {
// @public (undocumented)
export type HeightProps = GetPropDefTypes<typeof heightPropDefs>;
// @public (undocumented)
export const Hidden: (props: BoxProps) => JSX_2.Element;
// @public (undocumented)
export const Icon: (props: IconProps) => JSX_2.Element | null;
@@ -1413,6 +1410,7 @@ export function Row<T extends object>({
id,
columns,
children,
href,
...otherProps
}: RowProps<T>): JSX_2.Element;
@@ -1,32 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Meta, StoryObj } from '@storybook/react';
import { Hidden } from './Hidden';
const meta = {
title: 'Components/Hidden',
component: Hidden,
} satisfies Meta<typeof Hidden>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {
args: {
children: 'Visually hidden content',
},
};
@@ -1,28 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
.bui-Hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important; /* move it off-screen */
overflow: hidden !important;
clip: rect(0 0 0 0) !important; /* legacy clip for IE/Edge */
clip-path: inset(100%) !important; /* modern clip */
white-space: nowrap !important; /* avoid line breaks */
border: 0 !important;
}
@@ -1,23 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Box, BoxProps } from '../Box';
import clsx from 'clsx';
/** @public */
export const Hidden = (props: BoxProps) => {
return <Box {...props} className={clsx('bui-Hidden', props.className)} />;
};
@@ -1,17 +0,0 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export { Hidden } from './Hidden';
+1 -22
View File
@@ -20,28 +20,7 @@ import clsx from 'clsx';
import { useStyles } from '../../hooks/useStyles';
import type { LinkProps } from './types';
import { useNavigate, useHref } from 'react-router-dom';
// Helper function to determine if a link is external
function isExternalLink(href?: string): boolean {
if (!href) return false;
// Check if it's an absolute URL with protocol
if (href.startsWith('http://') || href.startsWith('https://')) {
return true;
}
// Check if it's a protocol-relative URL
if (href.startsWith('//')) {
return true;
}
// Check if it's a mailto: or tel: link
if (href.startsWith('mailto:') || href.startsWith('tel:')) {
return true;
}
return false;
}
import { isExternalLink } from '../../utils/isExternalLink';
/** @public */
export const Link = forwardRef<HTMLAnchorElement, LinkProps>((props, ref) => {
@@ -172,6 +172,98 @@ export const TableRockBand: Story = {
},
};
export const RowClick: Story = {
render: () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const newData = data4.slice(
pageIndex * pageSize,
(pageIndex + 1) * pageSize,
);
return (
<>
<Table>
<TableHeader>
<Column isRowHeader>Band name</Column>
<Column>Genre</Column>
<Column>Year formed</Column>
<Column>Albums</Column>
</TableHeader>
<TableBody>
{newData.map(item => (
<Row key={item.name} onAction={() => alert('Row clicked')}>
<CellProfileBUI
name={item.name}
src={item.image}
href={item.website}
/>
<Cell title={item.genre} />
<Cell title={item.yearFormed.toString()} />
<Cell title={item.albums.toString()} />
</Row>
))}
</TableBody>
</Table>
<TablePagination
pageIndex={pageIndex}
pageSize={pageSize}
rowCount={data4.length}
setPageIndex={setPageIndex}
setPageSize={setPageSize}
/>
</>
);
},
};
export const RowLink: Story = {
render: () => {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSize] = useState(5);
const newData = data4.slice(
pageIndex * pageSize,
(pageIndex + 1) * pageSize,
);
return (
<>
<Table>
<TableHeader>
<Column isRowHeader>Band name</Column>
<Column>Genre</Column>
<Column>Year formed</Column>
<Column>Albums</Column>
</TableHeader>
<TableBody>
{newData.map(item => (
<Row key={item.name} href="/band">
<CellProfileBUI
name={item.name}
src={item.image}
href={item.website}
/>
<Cell title={item.genre} />
<Cell title={item.yearFormed.toString()} />
<Cell title={item.albums.toString()} />
</Row>
))}
</TableBody>
</Table>
<TablePagination
pageIndex={pageIndex}
pageSize={pageSize}
rowCount={data4.length}
setPageIndex={setPageIndex}
setPageSize={setPageSize}
/>
</>
);
},
};
export const CellText: Story = {
render: () => {
return (
@@ -66,6 +66,10 @@
.bui-TableRow {
border-bottom: 1px solid var(--bui-border);
transition: color 0.2s ease-in-out;
&[data-react-aria-pressable='true'] {
cursor: pointer;
}
}
.bui-TableBody .bui-TableRow:hover {
@@ -21,28 +21,56 @@ import {
Cell,
Collection,
Checkbox,
RouterProvider,
} from 'react-aria-components';
import { useStyles } from '../../../hooks/useStyles';
import { useNavigate } from 'react-router-dom';
import { useHref } from 'react-router-dom';
import { isExternalLink } from '../../../utils/isExternalLink';
/** @public */
export function Row<T extends object>({
id,
columns,
children,
href,
...otherProps
}: RowProps<T>) {
const { classNames } = useStyles('Table');
const navigate = useNavigate();
const isExternal = isExternalLink(href);
let { selectionBehavior } = useTableOptions();
return (
<ReactAriaRow id={id} className={classNames.row} {...otherProps}>
const content = (
<>
{selectionBehavior === 'toggle' && (
<Cell>
<Checkbox slot="selection" />
</Cell>
)}
<Collection items={columns}>{children}</Collection>
</ReactAriaRow>
</>
);
if (isExternal) {
return (
<ReactAriaRow id={id} className={classNames.row} {...otherProps}>
{content}
</ReactAriaRow>
);
}
return (
<RouterProvider navigate={navigate} useHref={useHref}>
<ReactAriaRow
id={id}
className={classNames.row}
data-react-aria-pressable="true"
{...otherProps}
>
{content}
</ReactAriaRow>
</RouterProvider>
);
}
-1
View File
@@ -28,7 +28,6 @@
@import '../components/Grid/styles.css';
@import '../components/Header/Header.styles.css';
@import '../components/HeaderPage/HeaderPage.styles.css';
@import '../components/Hidden/Hidden.styles.css';
@import '../components/Icon/styles.css';
@import '../components/Link/styles.css';
@import '../components/Menu/Menu.styles.css';
-1
View File
@@ -37,7 +37,6 @@ export * from './components/Collapsible';
export * from './components/FieldLabel';
export * from './components/Header';
export * from './components/HeaderPage';
export * from './components/Hidden';
export * from './components/Icon';
export * from './components/ButtonIcon';
export * from './components/ButtonLink';
+42
View File
@@ -0,0 +1,42 @@
/*
* Copyright 2025 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* Determines if a link is external.
* @param href - The href of the link.
* @returns True if the link is external, false otherwise.
* @internal
*/
export function isExternalLink(href?: string): boolean {
if (!href) return false;
// Check if it's an absolute URL with protocol
if (href.startsWith('http://') || href.startsWith('https://')) {
return true;
}
// Check if it's a protocol-relative URL
if (href.startsWith('//')) {
return true;
}
// Check if it's a mailto: or tel: link
if (href.startsWith('mailto:') || href.startsWith('tel:')) {
return true;
}
return false;
}