feat(ui): implement comprehensive Table component system with compound API

This commit is contained in:
deytenit committed 2026-02-09 23:15:39 +03:00
1 parent df2abc06f2
commit 42a0b71a8f
27 files changed
+2016 -7

No files matched your search

@@ -26,7 +26,9 @@ type Story = StoryObj<typeof Code>;
// Helper to simulate highlighted lines
const MockHighlightedCode = () => (
<>
<span className="block text-[var(--rb-primary-text)]">import React from &apos;react&apos;;</span>
<span className="block text-[var(--rb-primary-text)]">
import React from &apos;react&apos;;
</span>
<span className="block text-[var(--rb-text)]"> </span>
<span className="block text-[var(--rb-secondary-text)]">
export const MyComponent = () =&gt; &#123;
@@ -117,8 +117,10 @@ export const LoadingState: Story = {
<div className="flex flex-col gap-2">
<span className="text-sm font-medium">Simulated Loading (Using Stub)</span>
<Image alt="Loading" ratio="16/9" src="https://invalid-url.jpg" width="300px" />
<p className="text-xs text-neutral-500">Note: This will stay in &quot;loading&quot; then &quot;error&quot; state because URL is invalid.</p>
<p className="text-xs text-neutral-500">
Note: This will stay in &quot;loading&quot; then &quot;error&quot; state because URL is
invalid.
</p>
</div>
),
};
@@ -0,0 +1,189 @@
import React from 'react';
import { Table } from './Table';
import { TableContainer } from './testing/TableContainer/TableContainer';
import type { Meta, StoryObj } from '@storybook/react';
const meta: Meta<typeof Table> = {
title: 'Components/Table',
component: Table,
tags: ['autodocs'],
argTypes: {
density: {
control: 'select',
options: ['s', 'm', 'l'],
},
variant: {
control: 'select',
options: ['surface', 'outline', 'ghost'],
},
},
};
export default meta;
type Story = StoryObj<typeof Table>;
const TableTemplate = (args: any) => (
<TableContainer variant={args.variant === 'ghost' ? 'ghost' : 'surface'}>
<Table {...args}>
<Table.Caption>A list of recent transactions.</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head className="w-[100px]">Invoice</Table.Head>
<Table.Head>Status</Table.Head>
<Table.Head>Method</Table.Head>
<Table.Head align="right">Amount</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell className="font-medium">INV001</Table.Cell>
<Table.Cell>Paid</Table.Cell>
<Table.Cell>Credit Card</Table.Cell>
<Table.Cell align="right">$250.00</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell className="font-medium">INV002</Table.Cell>
<Table.Cell>Pending</Table.Cell>
<Table.Cell>PayPal</Table.Cell>
<Table.Cell align="right">$150.00</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell className="font-medium">INV003</Table.Cell>
<Table.Cell>Unpaid</Table.Cell>
<Table.Cell>Bank Transfer</Table.Cell>
<Table.Cell align="right">$350.00</Table.Cell>
</Table.Row>
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.Cell colSpan={3}>Total</Table.Cell>
<Table.Cell align="right">$750.00</Table.Cell>
</Table.Row>
</Table.Footer>
</Table>
</TableContainer>
);
export const Default: Story = {
args: {
density: 'm',
variant: 'surface',
striped: false,
hoverable: true,
},
render: TableTemplate,
};
export const Striped: Story = {
args: {
...Default.args,
striped: true,
},
render: TableTemplate,
};
export const Compact: Story = {
args: {
...Default.args,
density: 's',
},
render: TableTemplate,
};
export const Ghost: Story = {
args: {
...Default.args,
variant: 'ghost',
hoverable: true,
},
render: TableTemplate,
};
export const Grid: Story = {
args: {
...Default.args,
grid: true,
},
render: TableTemplate,
};
export const BorderedCells: Story = {
args: {
...Default.args,
},
render: (args) => (
<TableContainer>
<Table {...args}>
<Table.Header>
<Table.Row>
<Table.Head border="right">Header with Right Border</Table.Head>
<Table.Head>Standard Header</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell border="all">All Borders</Table.Cell>
<Table.Cell border="x">X-Axis Borders</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell border="y">Y-Axis Borders</Table.Cell>
<Table.Cell border="left">Left Border Only</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</TableContainer>
),
};
export const ContentCellLayouts: Story = {
args: {
density: 'm',
variant: 'outline',
},
render: () => (
<TableContainer>
<Table>
<Table.Header>
<Table.Row>
<Table.Head>User</Table.Head>
<Table.Head>Details</Table.Head>
<Table.Head>Balance</Table.Head>
<Table.Head>Status</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>
<Table.Content gap="m">
<div className="w-8 h-8 rounded-full bg-blue-500 flex items-center justify-center text-white">
JD
</div>
<Table.Content gap="s" layout="stack">
<span className="font-bold">Jane Doe</span>
<span className="text-xs text-[var(--rb-muted-text)]">Admin</span>
</Table.Content>
</Table.Content>
</Table.Cell>
<Table.Cell>
<Table.Content maxLines={2}>
This is a very long description that should be truncated by the content helper after
two lines.
</Table.Content>
</Table.Cell>
<Table.Cell>
<Table.Content variant="numeric">$10,240.50</Table.Content>
</Table.Cell>
<Table.Cell>
<Table.Content variant="icon">
<div className="w-2 h-2 rounded-full bg-green-500" />
</Table.Content>
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
</TableContainer>
),
};
@@ -0,0 +1,117 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Table } from './Table';
import { TableContainer } from './testing/TableContainer/TableContainer';
describe('Table', () => {
it('renders correctly with all sub-components', () => {
render(
<TableContainer>
<Table>
<Table.Caption>Caption</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Head</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>Cell</Table.Cell>
</Table.Row>
</Table.Body>
<Table.Footer>
<Table.Row>
<Table.Cell>Foot</Table.Cell>
</Table.Row>
</Table.Footer>
</Table>
</TableContainer>
);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByText('Caption')).toBeInTheDocument();
expect(screen.getByText('Head')).toBeInTheDocument();
expect(screen.getByText('Cell')).toBeInTheDocument();
expect(screen.getByText('Foot')).toBeInTheDocument();
});
it('applies density classes from context to cells', () => {
render(
<Table density="s">
<Table.Body>
<Table.Row>
<Table.Cell>Small</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
);
// 's' density is 'p-2'
expect(screen.getByText('Small')).toHaveClass('p-2');
});
it('allows cell-level density overrides', () => {
render(
<Table density="s">
<Table.Body>
<Table.Row>
<Table.Cell padding="l">Large</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
);
// Overridden to 'l' which is 'p-6'
expect(screen.getByText('Large')).toHaveClass('p-6');
expect(screen.getByText('Large')).not.toHaveClass('p-2');
});
it('allows row-level border overrides', () => {
render(
<Table>
<Table.Body>
<Table.Row noBorder>
<Table.Cell data-testid="no-border-cell">No Border</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
);
// Now the cell handles the noBorder state
expect(screen.getByTestId('no-border-cell')).not.toHaveClass('border-b');
});
it('renders Table.Content with correct layout', () => {
render(
<Table.Content data-testid="numeric-content" variant="numeric">
123
</Table.Content>
);
const content = screen.getByTestId('numeric-content');
expect(content).toHaveClass('justify-end', 'tabular-nums');
});
it('TableContainer has accessibility attributes', () => {
render(
<TableContainer data-testid="container">
<Table />
</TableContainer>
);
const container = screen.getByTestId('container');
expect(container).toHaveAttribute('role', 'region');
expect(container).toHaveAttribute('tabindex', '0');
});
it('TableCell applies border classes', () => {
render(
<Table>
<Table.Body>
<Table.Row>
<Table.Cell border="all" data-testid="bordered-cell">
Bordered
</Table.Cell>
</Table.Row>
</Table.Body>
</Table>
);
expect(screen.getByTestId('bordered-cell')).toHaveClass('border');
});
});
+101
View File
@@ -0,0 +1,101 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { TableContext } from './contexts/TableContext/TableContext';
import { TableBody } from './TableBody/TableBody';
import { TableCaption } from './TableCaption/TableCaption';
import { TableCell } from './TableCell/TableCell';
import { TableContent } from './TableContent/TableContent';
import { TableFooter } from './TableFooter/TableFooter';
import { TableHead } from './TableHead/TableHead';
import { TableHeader } from './TableHeader/TableHeader';
import { TableRow } from './TableRow/TableRow';
import type { TableContextValue } from './types';
/**
* Props for the root Table component.
*/
export interface TableProps
extends React.TableHTMLAttributes<HTMLTableElement>,
Partial<TableContextValue> {
/**
* Whether the header remains visible during scroll.
* @default false
*/
stickyHeader?: boolean;
}
const TableComponent = forwardRef<HTMLTableElement, TableProps>(function Table(
{
className,
children,
density = 'm',
variant = 'surface',
striped = false,
hoverable = false,
grid = false,
stickyHeader = false,
...props
},
ref
) {
const contextValue: TableContextValue = { density, variant, striped, hoverable, grid };
return (
<TableContext.Provider value={contextValue}>
<table
{...props}
ref={castRef<HTMLTableElement>(ref)}
className={cn(
'w-full border-collapse caption-bottom text-sm',
// Mask borders at the edges to prevent doubling with container borders
'[&_td:first-child]:border-l-0 [&_th:first-child]:border-l-0',
'[&_td:last-child]:border-r-0 [&_th:last-child]:border-r-0',
'[&_tr:first-child_td]:border-t-0 [&_tr:first-child_th]:border-t-0',
'[&_tr:last-child_td]:border-b-0',
variant === 'ghost' && 'border-none',
stickyHeader && 'relative',
className
)}
>
{children}
</table>
</TableContext.Provider>
);
});
/**
* A comprehensive Table system component that uses a compound component pattern.
*
* @param props - Component properties
* @returns The rendered table system
*
* @example
* ```tsx
* <Table density="s" striped grid>
* <Table.Header>
* <Table.Row>
* <Table.Head>Name</Table.Head>
* <Table.Head>Role</Table.Head>
* </Table.Row>
* </Table.Header>
* <Table.Body>
* <Table.Row>
* <Table.Cell>Vladimir</Table.Cell>
* <Table.Cell>Developer</Table.Cell>
* </Table.Row>
* </Table.Body>
* </Table>
* ```
*/
export const Table = Object.assign(genericMemo(TableComponent), {
Header: TableHeader,
Body: TableBody,
Footer: TableFooter,
Row: TableRow,
Head: TableHead,
Cell: TableCell,
Caption: TableCaption,
Content: TableContent,
});
@@ -0,0 +1,29 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
/**
* A wrapper for the table body (tbody) element.
*
* @param props - Component properties
* @returns The rendered table body element
*
* @example
* ```tsx
* <Table.Body>
* <Table.Row>
* <Table.Cell>Data</Table.Cell>
* </Table.Row>
* </Table.Body>
* ```
*/
export const TableBody = genericMemo(
forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
function TableBody({ className, children, ...props }, ref) {
return (
<tbody {...props} ref={castRef<HTMLTableSectionElement>(ref)} className={cn(className)}>
{children}
</tbody>
);
}
)
);
@@ -0,0 +1,29 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
/**
* A wrapper for the table caption element.
*
* @param props - Component properties
* @returns The rendered table caption element
*
* @example
* ```tsx
* <Table.Caption>A list of your recent transactions.</Table.Caption>
* ```
*/
export const TableCaption = genericMemo(
forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
function TableCaption({ className, children, ...props }, ref) {
return (
<caption
{...props}
ref={castRef<HTMLTableCaptionElement>(ref)}
className={cn('mt-4 mb-4 text-sm text-[var(--rb-muted-text)]', className)}
>
{children}
</caption>
);
}
)
);
@@ -0,0 +1,81 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { paddingClasses, borderClasses } from '../constants';
import { useTableContext } from '../contexts/TableContext/TableContext';
import { useTableRowContext } from '../contexts/TableRowContext/TableRowContext';
import type { TablePadding, TableCellBorder } from '../types';
/**
* Props for the TableCell component.
*/
export interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> {
/** Padding override for the cell */
padding?: TablePadding;
/** Text alignment override */
align?: 'left' | 'center' | 'right';
/**
* Border configuration for the cell.
* If not provided, defaults to 'all' if Table grid is enabled,
* or 'bottom' unless the row/table is ghost.
*/
border?: TableCellBorder;
}
/**
* A standard table data cell (td) with density and alignment control.
*
* @param props - Component properties
* @returns The rendered table cell element
*
* @example
* ```tsx
* <Table.Cell align="right">1,234.56</Table.Cell>
* ```
*
* @example
* ```tsx
* <Table.Cell border="all">Bordered Cell</Table.Cell>
* ```
*/
export const TableCell = genericMemo(
forwardRef<HTMLTableCellElement, TableCellProps>(function TableCell(
{ className, children, padding, align, border, ...props },
ref
) {
const { density, grid } = useTableContext();
const { noBorder } = useTableRowContext();
const finalPadding = padding ?? density;
// Resolve the border to apply
let resolvedBorder: TableCellBorder;
if (border) {
resolvedBorder = border;
} else if (grid) {
resolvedBorder = 'all';
} else if (noBorder) {
resolvedBorder = 'none';
} else {
resolvedBorder = 'bottom';
}
return (
<td
{...props}
ref={castRef<HTMLTableCellElement>(ref)}
className={cn(
'align-middle',
paddingClasses[finalPadding],
borderClasses[resolvedBorder],
align === 'left' && 'text-left',
align === 'center' && 'text-center',
align === 'right' && 'text-right',
className
)}
>
{children}
</td>
);
})
);
@@ -0,0 +1,21 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { TableContent } from './TableContent';
describe('TableContent', () => {
it('renders correctly', () => {
render(<TableContent>Content</TableContent>);
expect(screen.getByText('Content')).toBeInTheDocument();
});
it('applies stack layout', () => {
render(<TableContent layout="stack">Stack</TableContent>);
expect(screen.getByText('Stack')).toHaveClass('flex-col');
});
it('applies truncate class', () => {
render(<TableContent truncate>Truncated</TableContent>);
expect(screen.getByText('Truncated')).toHaveClass('truncate');
});
});
@@ -0,0 +1,135 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
/**
* Props for the TableContent component.
*/
export interface TableContentProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Visual variant presets.
* @default 'text'
*/
variant?: 'text' | 'numeric' | 'icon' | 'checkbox';
/**
* Internal layout direction.
* @default 'flex'
*/
layout?: 'flex' | 'stack';
/** Flex alignment override */
align?: 'start' | 'center' | 'end' | 'between';
/** Spacing between items */
gap?: 's' | 'm' | 'l';
/**
* Whether to truncate text with ellipsis (single line).
* @default false
*/
truncate?: boolean;
/**
* Maximum number of lines to display before truncating with ellipsis.
* Overrides 'truncate' if provided.
*/
maxLines?: number;
/**
* Text color variant.
* @default 'default'
*/
color?: 'default' | 'muted' | 'error' | 'success';
}
const gapClasses: Record<string, string> = {
s: 'gap-1.5',
m: 'gap-3',
l: 'gap-4',
};
const colorClasses: Record<string, string> = {
default: 'text-[var(--rb-text)]',
muted: 'text-[var(--rb-muted-text)]',
error: 'text-[var(--rb-error-text)]',
success: 'text-[var(--rb-success-text)]',
};
/**
* A polymorphic layout primitive for content within table cells.
* Standardizes alignment, spacing, and truncation for common data types.
*
* @param props - Component properties
* @returns The rendered layout element
*
* @example
* ```tsx
* <Table.Content variant="numeric">$1,234.56</Table.Content>
* ```
*
* @example
* ```tsx
* <Table.Content maxLines={2}>
* This is a long description that will be truncated after two lines.
* </Table.Content>
* ```
*/
export const TableContent = genericMemo(
forwardRef<HTMLDivElement, TableContentProps>(function TableContent(
{
className,
children,
variant = 'text',
layout = 'flex',
align,
gap,
truncate = false,
maxLines,
color = 'default',
style,
...props
},
ref
) {
const alignClasses = {
start: layout === 'flex' ? 'justify-start' : 'items-start',
center: layout === 'flex' ? 'justify-center' : 'items-center',
end: layout === 'flex' ? 'justify-end' : 'items-end',
between: layout === 'flex' ? 'justify-between' : 'items-stretch',
};
// Determine default alignment based on variant if not explicitly provided
let defaultAlign: keyof typeof alignClasses = 'start';
if (variant === 'numeric') defaultAlign = 'end';
else if (variant === 'icon' || variant === 'checkbox') defaultAlign = 'center';
const finalAlign = align ?? defaultAlign;
const alignmentClass = alignClasses[finalAlign];
// Truncation logic
const isTruncated = truncate || maxLines !== undefined;
const lineCount = maxLines ?? (truncate ? 1 : undefined);
return (
<div
{...props}
ref={castRef<HTMLDivElement>(ref)}
className={cn(
'flex',
layout === 'flex' ? 'flex-row items-center' : 'flex-col',
alignmentClass,
gap && gapClasses[gap],
colorClasses[color],
// When truncating, we need to handle block layout and min-width
isTruncated && (lineCount === 1 ? 'truncate block' : 'line-clamp'),
isTruncated && 'min-w-0',
variant === 'numeric' && 'tabular-nums',
variant === 'checkbox' && 'w-min mx-auto',
className
)}
style={{
...(lineCount && lineCount > 1
? { WebkitLineClamp: lineCount, display: '-webkit-box', WebkitBoxOrient: 'vertical' }
: {}),
...style,
}}
>
{children}
</div>
);
})
);
@@ -0,0 +1,36 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
/**
* A wrapper for the table footer (tfoot) element.
*
* @param props - Component properties
* @returns The rendered table footer element
*
* @example
* ```tsx
* <Table.Footer>
* <Table.Row>
* <Table.Cell>Total</Table.Cell>
* </Table.Row>
* </Table.Footer>
* ```
*/
export const TableFooter = genericMemo(
forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
function TableFooter({ className, children, ...props }, ref) {
return (
<tfoot
{...props}
ref={castRef<HTMLTableSectionElement>(ref)}
className={cn(
'bg-[var(--rb-muted-base)]/50 font-medium [&>tr]:last:border-b-0',
className
)}
>
{children}
</tfoot>
);
}
)
);
@@ -0,0 +1,76 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { paddingClasses, borderClasses } from '../constants';
import { useTableContext } from '../contexts/TableContext/TableContext';
import { useTableRowContext } from '../contexts/TableRowContext/TableRowContext';
import type { TablePadding, TableCellBorder } from '../types';
/**
* Props for the TableHead component.
*/
export interface TableHeadProps extends React.ThHTMLAttributes<HTMLTableCellElement> {
/** Padding override for the cell */
padding?: TablePadding;
/** Text alignment override */
align?: 'left' | 'center' | 'right';
/**
* Border configuration for the cell.
* If not provided, defaults to 'all' if Table grid is enabled,
* or 'bottom' unless the row/table is ghost.
*/
border?: TableCellBorder;
}
/**
* A table header cell (th) with density and alignment control.
*
* @param props - Component properties
* @returns The rendered table header element
*
* @example
* ```tsx
* <Table.Head>Invoice Number</Table.Head>
* ```
*/
export const TableHead = genericMemo(
forwardRef<HTMLTableCellElement, TableHeadProps>(function TableHead(
{ className, children, padding, align = 'left', border, ...props },
ref
) {
const { density, grid } = useTableContext();
const { noBorder } = useTableRowContext();
const finalPadding = padding ?? density;
// Resolve the border to apply
let resolvedBorder: TableCellBorder;
if (border) {
resolvedBorder = border;
} else if (grid) {
resolvedBorder = 'all';
} else if (noBorder) {
resolvedBorder = 'none';
} else {
resolvedBorder = 'bottom';
}
return (
<th
{...props}
ref={castRef<HTMLTableCellElement>(ref)}
className={cn(
'align-middle font-medium text-[var(--rb-muted-text)]',
paddingClasses[finalPadding],
borderClasses[resolvedBorder],
align === 'left' && 'text-left',
align === 'center' && 'text-center',
align === 'right' && 'text-right',
className
)}
>
{children}
</th>
);
})
);
@@ -0,0 +1,29 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
/**
* A wrapper for the table head (thead) element.
*
* @param props - Component properties
* @returns The rendered table head element
*
* @example
* ```tsx
* <Table.Header>
* <Table.Row>
* <Table.Head>Header</Table.Head>
* </Table.Row>
* </Table.Header>
* ```
*/
export const TableHeader = genericMemo(
forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
function TableHeader({ className, children, ...props }, ref) {
return (
<thead {...props} ref={castRef<HTMLTableSectionElement>(ref)} className={cn(className)}>
{children}
</thead>
);
}
)
);
@@ -0,0 +1,33 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import { Table } from '../Table';
import { TableRow } from './TableRow';
describe('TableRow', () => {
it('renders correctly', () => {
render(
<Table>
<tbody>
<TableRow>
<td>Row</td>
</TableRow>
</tbody>
</Table>
);
expect(screen.getByText('Row').closest('tr')).toBeInTheDocument();
});
it('applies selected class', () => {
render(
<Table>
<tbody>
<TableRow selected data-testid="row">
<td>Selected</td>
</TableRow>
</tbody>
</Table>
);
expect(screen.getByTestId('row')).toHaveClass('bg-[var(--rb-muted-base)]');
});
});
@@ -0,0 +1,68 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
import { useTableContext } from '../contexts/TableContext/TableContext';
import { TableRowContext } from '../contexts/TableRowContext/TableRowContext';
/**
* Props for the TableRow component.
*/
export interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
/**
* Removes the bottom border.
* @default false
*/
noBorder?: boolean;
/**
* Overrides global hover state.
* If not provided, inherits from Table context.
*/
hoverable?: boolean;
/**
* Forces selected/highlighted state.
* @default false
*/
selected?: boolean;
}
/**
* A table row component (tr) with hover, stripe, and border controls.
*
* @param props - Component properties
* @returns The rendered table row element
*
* @example
* ```tsx
* <Table.Row selected hoverable>
* <Table.Cell>Row Data</Table.Cell>
* </Table.Row>
* ```
*/
export const TableRow = genericMemo(
forwardRef<HTMLTableRowElement, TableRowProps>(function TableRow(
{ className, children, noBorder, hoverable, selected, ...props },
ref
) {
const context = useTableContext();
const isHoverable = hoverable ?? context.hoverable;
const isNoBorder = noBorder ?? context.variant === 'ghost';
return (
<TableRowContext.Provider value={{ noBorder: isNoBorder }}>
<tr
{...props}
ref={castRef<HTMLTableRowElement>(ref)}
className={cn(
'transition-colors',
isHoverable && 'hover:bg-[var(--rb-muted-base)]/50',
context.striped && 'even:bg-[var(--rb-muted-base)]/20',
selected && 'bg-[var(--rb-muted-base)]',
className
)}
>
{children}
</tr>
</TableRowContext.Provider>
);
})
);
@@ -0,0 +1,25 @@
import type { TablePadding, TableCellBorder } from './types';
/**
* Shared padding classes based on table density.
*/
export const paddingClasses: Record<TablePadding, string> = {
none: 'p-0',
s: 'p-2',
m: 'p-4',
l: 'p-6',
};
/**
* Shared border classes for table cells.
*/
export const borderClasses: Record<TableCellBorder, string> = {
none: '',
all: 'border border-[var(--rb-border)]',
top: 'border-t border-[var(--rb-border)]',
bottom: 'border-b border-[var(--rb-border)]',
left: 'border-l border-[var(--rb-border)]',
right: 'border-r border-[var(--rb-border)]',
x: 'border-x border-[var(--rb-border)]',
y: 'border-y border-[var(--rb-border)]',
};
@@ -0,0 +1,29 @@
import { createContext, useContext } from 'react';
import type { TableContextValue } from '../../types';
/**
* Context for sharing table configuration (density, variant, etc.)
* with sub-components.
*/
export const TableContext = createContext<TableContextValue | null>(null);
/**
* Hook to access the Table configuration context.
* Must be used within a Table component.
*
* @returns The current table context value.
* @throws Error if used outside of a Table component.
*
* @example
* ```tsx
* const { density, striped } = useTableContext();
* ```
*/
export function useTableContext(): TableContextValue {
const context = useContext(TableContext);
if (!context) {
throw new Error('Table sub-components must be used within a Table component.');
}
return context;
}
@@ -0,0 +1,24 @@
import { createContext, useContext } from 'react';
/**
* Context for sharing row-specific configuration with cells.
*/
export interface TableRowContextValue {
/** Whether the row should have borders */
noBorder?: boolean;
}
export const TableRowContext = createContext<TableRowContextValue>({ noBorder: false });
/**
* Hook to access the TableRow context.
*
* @returns The current table row context value.
* @example
* ```tsx
* const { noBorder } = useTableRowContext();
* ```
*/
export function useTableRowContext(): TableRowContextValue {
return useContext(TableRowContext);
}
+15
View File
@@ -0,0 +1,15 @@
export * from './Table';
export * from './types';
export * from './constants';
export * from './contexts/TableContext/TableContext';
export * from './contexts/TableRowContext/TableRowContext';
// Export sub-components individually for flexibility
export * from './TableHeader/TableHeader';
export * from './TableBody/TableBody';
export * from './TableFooter/TableFooter';
export * from './TableRow/TableRow';
export * from './TableHead/TableHead';
export * from './TableCell/TableCell';
export * from './TableCaption/TableCaption';
export * from './TableContent/TableContent';
@@ -0,0 +1,51 @@
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
import React, { forwardRef } from 'react';
/**
* Props for the TableContainer component.
*/
export interface TableContainerProps extends React.HTMLAttributes<HTMLDivElement> {
/**
* Visual variant of the container.
* If 'ghost', the outer border is removed.
* @default 'surface'
*/
variant?: 'surface' | 'ghost';
}
/**
* A responsive wrapper for the Table component.
* Handles horizontal scrolling, outer borders, and accessibility for scrollable regions.
*
* @param props - Component properties
* @returns The rendered container element
*
* @example
* ```tsx
* <TableContainer>
* <Table>...</Table>
* </TableContainer>
* ```
*/
export const TableContainer = genericMemo(
forwardRef<HTMLDivElement, TableContainerProps>(function TableContainer(
{ className, children, variant = 'surface', ...props },
ref
) {
return (
<div
{...props}
ref={castRef<HTMLDivElement>(ref)}
className={cn(
'w-full overflow-auto rounded-md',
variant !== 'ghost' && 'border border-[var(--rb-border)]',
className
)}
role="region"
tabIndex={0}
>
{children}
</div>
);
})
);
+42
View File
@@ -0,0 +1,42 @@
/**
* Table density options.
* - 's': Small (compact)
* - 'm': Medium (standard)
* - 'l': Large (spacious)
*/
export type TableDensity = 's' | 'm' | 'l';
/**
* Table visual variants.
* - 'surface': Standard with background
* - 'outline': Bordered without background
* - 'ghost': No borders or background
*/
export type TableVariant = 'surface' | 'outline' | 'ghost';
/**
* Table padding options.
* Includes all density presets and 'none'.
*/
export type TablePadding = 'none' | TableDensity;
/**
* Table cell border options.
*/
export type TableCellBorder = 'none' | 'all' | 'top' | 'bottom' | 'left' | 'right' | 'x' | 'y';
/**
* Global configuration for the Table system.
*/
export interface TableContextValue {
/** The density of the table rows and cells */
density: TableDensity;
/** The visual style of the table */
variant: TableVariant;
/** Whether rows should have alternating background colors */
striped: boolean;
/** Whether rows should highlight on hover */
hoverable: boolean;
/** Whether to show borders between all cells (grid mode) */
grid?: boolean;
}
+13
View File
@@ -15,6 +15,8 @@ export * from './Blockquote';
export * from './Stub/Stub';
export * from './Image/Image';
export * from './Table';
export type { StackProps } from './Stack/Stack';
export type { ListProps, ListVariant, ListSpacing, ListItemProps } from './List/List';
export type {
@@ -31,3 +33,14 @@ export type { CodeProps } from './Code/Code';
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
export type { StubProps } from './Stub/Stub';
export type { ImageProps } from './Image/Image';
export type {
TableProps,
TableDensity,
TableVariant,
TablePadding,
TableRowProps,
TableHeadProps,
TableCellProps,
TableCellBorder,
TableContentProps as TableContentCellProps,
} from './Table';