Add onRowClick event + docs improvements
Signed-off-by: Charles de Dreuille <charles.dedreuille@gmail.com>
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -1,6 +1,205 @@
|
||||
import { PropsTable } from '@/components/PropsTable';
|
||||
import { Snippet } from '@/components/Snippet';
|
||||
import { CodeBlock } from '@/components/CodeBlock';
|
||||
import { TableSnippet } from '@/snippets/stories-snippets';
|
||||
import {
|
||||
tablePropDefs,
|
||||
tablePaginationPropDefs,
|
||||
tableUsageSnippet,
|
||||
tableBasicSnippet,
|
||||
tableRowClickSnippet,
|
||||
tableHybridSnippet,
|
||||
tableCellInteractionsSnippet,
|
||||
tablePaginationSnippet,
|
||||
tableSelectionSnippet,
|
||||
tableSortingSnippet,
|
||||
} from './table.props';
|
||||
import { ChangelogComponent } from '@/components/ChangelogComponent';
|
||||
import { PageTitle } from '@/components/PageTitle';
|
||||
import { Theming } from '@/components/Theming';
|
||||
|
||||
<PageTitle
|
||||
title="Table"
|
||||
description="A table component that can be used to display data in a grid. (Coming soon)"
|
||||
description="A flexible table component built on top of TanStack Table with built-in styling, interactions, and pagination support."
|
||||
/>
|
||||
|
||||
<Snippet
|
||||
py={4}
|
||||
preview={<TableSnippet story="Uncontrolled" />}
|
||||
code={tableBasicSnippet}
|
||||
/>
|
||||
|
||||
## Usage
|
||||
|
||||
<CodeBlock code={tableUsageSnippet} />
|
||||
|
||||
## API reference
|
||||
|
||||
### Table
|
||||
|
||||
The main table component that renders data in a structured grid format.
|
||||
|
||||
<PropsTable data={tablePropDefs} />
|
||||
|
||||
### TablePagination
|
||||
|
||||
A pagination component designed to work with the Table component.
|
||||
|
||||
<PropsTable data={tablePaginationPropDefs} />
|
||||
|
||||
## Examples
|
||||
|
||||
### Basic Table
|
||||
|
||||
A simple table with data display.
|
||||
|
||||
<Snippet
|
||||
py={4}
|
||||
open
|
||||
preview={<TableSnippet story="Uncontrolled" />}
|
||||
code={tableBasicSnippet}
|
||||
/>
|
||||
|
||||
### Row Clicks
|
||||
|
||||
The Table supports a hybrid interaction model where you can add row-level click handlers while still allowing cell-level interactions to take precedence.
|
||||
|
||||
<Snippet
|
||||
py={4}
|
||||
open
|
||||
preview={<TableSnippet story="WithRowClick" />}
|
||||
code={tableRowClickSnippet}
|
||||
/>
|
||||
|
||||
### Hybrid Interactions
|
||||
|
||||
This demonstrates the most common pattern: specific cells (like names) have links for navigation, while clicking empty row areas triggers a different action like selection or quick preview.
|
||||
|
||||
<Snippet
|
||||
py={4}
|
||||
open
|
||||
preview={<TableSnippet story="WithNavigationLinks" />}
|
||||
code={tableHybridSnippet}
|
||||
/>
|
||||
|
||||
### Cell-Level Interactions
|
||||
|
||||
Different cells can have completely different interaction behaviors. Links navigate, buttons trigger actions, and read-only cells allow row clicks.
|
||||
|
||||
<Snippet
|
||||
py={4}
|
||||
open
|
||||
preview={<TableSnippet story="WithMixedInteractions" />}
|
||||
code={tableCellInteractionsSnippet}
|
||||
/>
|
||||
|
||||
### Pagination
|
||||
|
||||
Enable pagination by adding the pagination row model and using the TablePagination component.
|
||||
|
||||
<Snippet
|
||||
py={4}
|
||||
open
|
||||
preview={<TableSnippet story="Controlled" />}
|
||||
code={tablePaginationSnippet}
|
||||
/>
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Row Selection
|
||||
|
||||
Enable row selection using TanStack Table's built-in selection features.
|
||||
|
||||
<CodeBlock code={tableSelectionSnippet} />
|
||||
|
||||
### Sorting
|
||||
|
||||
Enable column sorting by adding the sorting row model.
|
||||
|
||||
<CodeBlock code={tableSortingSnippet} />
|
||||
|
||||
## Interaction Patterns
|
||||
|
||||
The Table component supports a **two-level interaction model**:
|
||||
|
||||
1. **Primary**: Cell-level interactions (Links, buttons, etc.) - handled in column definitions
|
||||
2. **Secondary**: Row-level background handler - handles clicks on empty row areas
|
||||
|
||||
### When to Use Each Pattern
|
||||
|
||||
**Use `onRowClick` only:**
|
||||
|
||||
- Simple tables where entire rows navigate to the same place
|
||||
- Selection-based interactions
|
||||
- Quick actions that apply to the whole row
|
||||
|
||||
**Use cell-level interactions only:**
|
||||
|
||||
- Complex tables with many different interactive elements
|
||||
- When you need precise control over each interaction
|
||||
- When different cells need different behaviors
|
||||
|
||||
**Use hybrid approach:**
|
||||
|
||||
- **Most common case** - Some cells have specific actions, others are background-clickable
|
||||
- Data tables with primary actions (links) and secondary actions (selection/preview)
|
||||
- When you want the convenience of row clicking but flexibility of cell control
|
||||
|
||||
### Event Handling
|
||||
|
||||
Cell-level interactions should call `e.stopPropagation()` to prevent row clicks:
|
||||
|
||||
```tsx
|
||||
// ✅ Correct - prevents row click
|
||||
<Link
|
||||
to="/somewhere"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Navigate
|
||||
</Link>
|
||||
|
||||
// ✅ Also correct for custom actions
|
||||
<button onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAction();
|
||||
}}>
|
||||
Action
|
||||
</button>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Use Link components for navigation** - Better UX and accessibility
|
||||
2. **Handle keyboard modifiers** - Support Cmd/Ctrl+click for new tabs
|
||||
3. **Use `stopPropagation()`** in cell interactions to prevent row clicks
|
||||
4. **Provide visual feedback** - Use appropriate cursor styles
|
||||
5. **Consider accessibility** - Screen readers understand Links and buttons in context
|
||||
|
||||
## TypeScript Support
|
||||
|
||||
The Table component is fully typed with generic support:
|
||||
|
||||
```tsx
|
||||
interface MyDataType {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const table = useReactTable<MyDataType>({
|
||||
data: myData, // ✅ Typed as MyDataType[]
|
||||
columns: myColumns, // ✅ Typed as ColumnDef<MyDataType>[]
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const handleRowClick = (row: MyDataType) => {
|
||||
// ✅ row is fully typed as MyDataType
|
||||
console.log(row.name);
|
||||
};
|
||||
|
||||
return <Table table={table} onRowClick={handleRowClick} />;
|
||||
```
|
||||
|
||||
<Theming component="Table" />
|
||||
|
||||
<ChangelogComponent component="table" />
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
import {
|
||||
classNamePropDefs,
|
||||
stylePropDefs,
|
||||
type PropDef,
|
||||
} 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,
|
||||
},
|
||||
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,
|
||||
},
|
||||
...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,
|
||||
},
|
||||
};
|
||||
|
||||
export const tableUsageSnippet = `import { useReactTable, getCoreRowModel, ColumnDef } from '@tanstack/react-table';
|
||||
import { Table, TablePagination } from '@backstage/ui/components/Table';
|
||||
|
||||
interface Person {
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
age: number;
|
||||
visits: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
const data: Person[] = [
|
||||
{
|
||||
firstName: 'tanner',
|
||||
lastName: 'linsley',
|
||||
age: 24,
|
||||
visits: 100,
|
||||
status: 'In Relationship',
|
||||
},
|
||||
// ... more data
|
||||
];
|
||||
|
||||
const columns: ColumnDef<Person>[] = [
|
||||
{
|
||||
accessorKey: 'firstName',
|
||||
header: 'First Name',
|
||||
},
|
||||
{
|
||||
accessorKey: 'lastName',
|
||||
header: 'Last Name',
|
||||
},
|
||||
{
|
||||
accessorKey: 'age',
|
||||
header: 'Age',
|
||||
},
|
||||
{
|
||||
accessorKey: 'visits',
|
||||
header: 'Visits',
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
},
|
||||
];
|
||||
|
||||
function MyTable() {
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table table={table} />
|
||||
<TablePagination table={table} />
|
||||
</>
|
||||
);
|
||||
}`;
|
||||
|
||||
export const tableBasicSnippet = `const table = useReactTable({
|
||||
data: myData,
|
||||
columns: myColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
return <Table table={table} />;`;
|
||||
|
||||
export const tableRowClickSnippet = `const table = useReactTable({
|
||||
data: myData,
|
||||
columns: myColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const handleRowClick = (rowData: MyDataType) => {
|
||||
console.log('Row clicked:', rowData);
|
||||
navigate(\`/details/\${rowData.id}\`);
|
||||
};
|
||||
|
||||
return <Table table={table} onRowClick={handleRowClick} />;`;
|
||||
|
||||
export const tableHybridSnippet = `const columns: ColumnDef<MyDataType>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<Link
|
||||
to={\`/items/\${row.original.id}\`}
|
||||
onClick={(e) => e.stopPropagation()} // Prevent row click
|
||||
>
|
||||
<TableCellText title={row.getValue('name')} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'status',
|
||||
header: 'Status',
|
||||
cell: ({ row }) => <TableCellText title={row.getValue('status')} />,
|
||||
},
|
||||
];
|
||||
|
||||
const table = useReactTable({
|
||||
data: myData,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const handleRowClick = (rowData: MyDataType) => {
|
||||
// Called when clicking empty row areas (not the name link)
|
||||
showQuickPreview(rowData);
|
||||
};
|
||||
|
||||
return <Table table={table} onRowClick={handleRowClick} />;`;
|
||||
|
||||
export const tableCellInteractionsSnippet = `const columns: ColumnDef<MyDataType>[] = [
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<Link to={\`/items/\${row.original.id}\`}>
|
||||
<TableCellText title={row.getValue('name')} />
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: 'owner',
|
||||
header: 'Owner',
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openContactModal(row.original.owner);
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<TableCellText title={row.original.owner.name} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
cell: ({ row }) => (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleEdit(row.original);
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];`;
|
||||
|
||||
export const tablePaginationSnippet = `import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getPaginationRowModel
|
||||
} from '@tanstack/react-table';
|
||||
import { Table, TablePagination } from '@backstage/ui/components/Table';
|
||||
|
||||
const table = useReactTable({
|
||||
data: myData,
|
||||
columns: myColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(), // Enable pagination
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table table={table} />
|
||||
<TablePagination table={table} />
|
||||
</>
|
||||
);`;
|
||||
|
||||
export const tableSelectionSnippet = `import { useState } from 'react';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
RowSelectionState
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
|
||||
|
||||
const table = useReactTable({
|
||||
data: myData,
|
||||
columns: myColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
enableRowSelection: true,
|
||||
state: {
|
||||
rowSelection,
|
||||
},
|
||||
onRowSelectionChange: setRowSelection,
|
||||
});
|
||||
|
||||
// Access selected rows
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows;
|
||||
|
||||
return <Table table={table} />;`;
|
||||
|
||||
export const tableSortingSnippet = `import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel
|
||||
} from '@tanstack/react-table';
|
||||
|
||||
const table = useReactTable({
|
||||
data: myData,
|
||||
columns: myColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(), // Enable sorting
|
||||
});
|
||||
|
||||
return <Table table={table} />;`;
|
||||
@@ -26,6 +26,7 @@ import * as SkeletonStories from '../../../packages/ui/src/components/Skeleton/S
|
||||
import * as CardStories from '../../../packages/ui/src/components/Card/Card.stories';
|
||||
import * as HeaderStories from '../../../packages/ui/src/components/Header/Header.stories';
|
||||
import * as HeaderPageStories from '../../../packages/ui/src/components/HeaderPage/HeaderPage.stories';
|
||||
import * as TableStories from '../../../packages/ui/src/components/Table/Table.stories';
|
||||
|
||||
// Helper function to create snippet components
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
@@ -67,3 +68,4 @@ export const SkeletonSnippet = createSnippetComponent(SkeletonStories);
|
||||
export const CardSnippet = createSnippetComponent(CardStories);
|
||||
export const HeaderSnippet = createSnippetComponent(HeaderStories);
|
||||
export const HeaderPageSnippet = createSnippetComponent(HeaderPageStories);
|
||||
export const TableSnippet = createSnippetComponent(TableStories);
|
||||
|
||||
@@ -24,9 +24,11 @@ import {
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
PaginationState,
|
||||
ColumnDef,
|
||||
} from '@tanstack/react-table';
|
||||
import { useState } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { TableCellText } from './TableCellText/TableCellText';
|
||||
|
||||
const meta = {
|
||||
title: 'Components/Table',
|
||||
@@ -89,3 +91,73 @@ export const Controlled: Story = {
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithRowClick: Story = {
|
||||
render: () => {
|
||||
const table = useReactTable<DataProps>({
|
||||
data,
|
||||
columns, // Use default columns with no custom cell interactions
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
const handleRowClick = (rowData: DataProps) => {
|
||||
console.log('Pure row click:', rowData.name);
|
||||
alert(`Navigating to: ${rowData.name}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table table={table} onRowClick={handleRowClick} />
|
||||
<TablePagination table={table} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export const WithClickableCells: Story = {
|
||||
render: () => {
|
||||
// Create columns with clickable name cells
|
||||
const clickableColumns: ColumnDef<DataProps>[] = [
|
||||
...columns.slice(0, 1), // Keep select and name columns, but modify name
|
||||
{
|
||||
accessorKey: 'name',
|
||||
header: 'Name',
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
onClick={e => {
|
||||
e.stopPropagation(); // Prevent row click
|
||||
alert(`Clicked on: ${row.original.name}`);
|
||||
console.log('Cell clicked:', row.original);
|
||||
}}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<TableCellText
|
||||
title={row.getValue('name')}
|
||||
description={row.original.description}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
size: 450,
|
||||
enableSorting: false,
|
||||
},
|
||||
...columns.slice(2), // Keep remaining columns
|
||||
];
|
||||
|
||||
const table = useReactTable<DataProps>({
|
||||
data,
|
||||
columns: clickableColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table table={table} />
|
||||
<TablePagination table={table} />
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ function getAriaSort(sortDirection: string | false) {
|
||||
export function Table<TData>(
|
||||
props: TableProps<TData> & { ref?: React.ForwardedRef<HTMLTableElement> },
|
||||
) {
|
||||
const { className, table, ref, ...rest } = props;
|
||||
const { className, table, onRowClick, ref, ...rest } = props;
|
||||
|
||||
return (
|
||||
<RawTable
|
||||
@@ -76,11 +76,11 @@ export function Table<TData>(
|
||||
<RawTableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map(row => {
|
||||
const rowData = row.original as TData & { onClick?: () => void };
|
||||
const handleRowClick = rowData.onClick
|
||||
const handleRowClick = onRowClick
|
||||
? (e: React.MouseEvent<HTMLTableRowElement>) => {
|
||||
// Only call onRowClick if the event hasn't been handled by a child element
|
||||
if (!e.isPropagationStopped()) {
|
||||
rowData.onClick!();
|
||||
onRowClick(row.original, e);
|
||||
}
|
||||
}
|
||||
: undefined;
|
||||
@@ -89,7 +89,7 @@ export function Table<TData>(
|
||||
<RawTableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && 'selected'}
|
||||
data-clickable={!!rowData.onClick}
|
||||
data-clickable={!!onRowClick}
|
||||
onClick={handleRowClick}
|
||||
>
|
||||
{row.getVisibleCells().map(cell => (
|
||||
|
||||
@@ -20,4 +20,13 @@ import { Table } from '@tanstack/react-table';
|
||||
export interface TableProps<TData>
|
||||
extends React.HTMLAttributes<HTMLTableElement> {
|
||||
table: Table<TData>;
|
||||
/**
|
||||
* Background click handler for rows. This will be called when clicking on empty
|
||||
* areas of a row that don't have their own click handlers. Cell-level interactions
|
||||
* (like Links or buttons) will automatically prevent this from firing.
|
||||
*/
|
||||
onRowClick?: (
|
||||
row: TData,
|
||||
event: React.MouseEvent<HTMLTableRowElement>,
|
||||
) => void;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user