feat(ui): implement comprehensive Table component system with compound API
This commit is contained in:
27 files changed
+2016
-7
No files matched your search
File renamed without changes.
@@ -0,0 +1,482 @@
|
|||||||
|
# Rainby Table Components Implementation Plan
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Implement a comprehensive, accessible, and composable set of Table components (Table, Thead, Tbody, Tr, Th, Td, Caption) and a specialized Cell layout primitive, adhering to the Rainby Design System.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
A Compound Component pattern using React Context (`TableContext`) to propagate high-level style props (`density`, `variant`, `striped`) from the root `Table` to all child components. This ensures API cleanliness and visual consistency. The `Cell` component serves as a polymorphic layout helper for cell content.
|
||||||
|
|
||||||
|
**Tech Stack:** React, Tailwind CSS, TypeScript, `class-variance-authority` (or manual `cn` utility), `genericMemo` from internal STL.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Setup TableContext and Types
|
||||||
|
|
||||||
|
Define the context that will share styling configuration across the table sub-components.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/ui/src/components/Table/TableContext.tsx`
|
||||||
|
- Create: `packages/ui/src/components/Table/types.ts`
|
||||||
|
|
||||||
|
**Step 1: Define shared types**
|
||||||
|
Create `packages/ui/src/components/Table/types.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type TableDensity = 's' | 'm' | 'l';
|
||||||
|
export type TableVariant = 'surface' | 'outline' | 'ghost';
|
||||||
|
|
||||||
|
export interface TableContextValue {
|
||||||
|
density: TableDensity;
|
||||||
|
variant: TableVariant;
|
||||||
|
striped: boolean;
|
||||||
|
hoverable: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create Context**
|
||||||
|
Create `packages/ui/src/components/Table/TableContext.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { TableContextValue } from './types';
|
||||||
|
|
||||||
|
const TableContext = createContext<TableContextValue | null>(null);
|
||||||
|
|
||||||
|
export const useTableContext = () => {
|
||||||
|
const context = useContext(TableContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Table sub-components must be used within a Table component.');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const TableProvider = TableContext.Provider;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/types.ts packages/ui/src/components/Table/TableContext.tsx
|
||||||
|
git commit -m "feat(ui): add TableContext and types"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Implement Table Container and Root Component
|
||||||
|
|
||||||
|
Implement `TableContainer` (wrapper) and `Table` (root element with Provider).
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/ui/src/components/Table/Table.tsx`
|
||||||
|
- Create: `packages/ui/src/components/Table/Table.test.tsx` (Basic render test)
|
||||||
|
|
||||||
|
**Step 1: Write minimal test**
|
||||||
|
Create `packages/ui/src/components/Table/Table.test.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Table, TableContainer } from './Table';
|
||||||
|
|
||||||
|
describe('Table', () => {
|
||||||
|
it('renders correctly', () => {
|
||||||
|
render(
|
||||||
|
<TableContainer>
|
||||||
|
<Table density="m">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td>Test</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Test')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement Components**
|
||||||
|
Create `packages/ui/src/components/Table/Table.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { cn, castRef } from '@ermnvldmr/stl';
|
||||||
|
import { TableProvider } from './TableContext';
|
||||||
|
import type { TableContextValue } from './types';
|
||||||
|
|
||||||
|
// TableContainer
|
||||||
|
export interface TableContainerProps extends React.HTMLAttributes<HTMLDivElement> {}
|
||||||
|
|
||||||
|
export const TableContainer = forwardRef<HTMLDivElement, TableContainerProps>(
|
||||||
|
({ className, children, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn('w-full overflow-auto rounded-md border border-border', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableContainer.displayName = 'TableContainer';
|
||||||
|
|
||||||
|
// Table
|
||||||
|
export interface TableProps extends React.TableHTMLAttributes<HTMLTableElement>, Partial<TableContextValue> {
|
||||||
|
stickyHeader?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Table = forwardRef<HTMLTableElement, TableProps>(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
density = 'm',
|
||||||
|
variant = 'surface',
|
||||||
|
striped = false,
|
||||||
|
hoverable = false,
|
||||||
|
stickyHeader = false,
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
const contextValue: TableContextValue = { density, variant, striped, hoverable };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableProvider value={contextValue}>
|
||||||
|
<table
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'w-full caption-bottom text-sm',
|
||||||
|
variant === 'ghost' && 'border-none',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</table>
|
||||||
|
</TableProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Table.displayName = 'Table';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run Test**
|
||||||
|
`npm test packages/ui/src/components/Table/Table.test.tsx`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/Table.tsx packages/ui/src/components/Table/Table.test.tsx
|
||||||
|
git commit -m "feat(ui): implement Table and TableContainer components"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Implement Semantic Table Sections (Header, Body, Footer, Caption)
|
||||||
|
|
||||||
|
Simple wrappers that forward refs and apply minimal base styles.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/ui/src/components/Table/Table.tsx` (Add exports)
|
||||||
|
- Modify: `packages/ui/src/components/Table/Table.test.tsx` (Update test)
|
||||||
|
|
||||||
|
**Step 1: Extend Test**
|
||||||
|
Add check for caption and section roles in `packages/ui/src/components/Table/Table.test.tsx`.
|
||||||
|
|
||||||
|
**Step 2: Add Components to Table.tsx**
|
||||||
|
Append to `packages/ui/src/components/Table/Table.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// TableHeader
|
||||||
|
export const TableHeader = forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableHeader.displayName = 'TableHeader';
|
||||||
|
|
||||||
|
// TableBody
|
||||||
|
export const TableBody = forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableBody.displayName = 'TableBody';
|
||||||
|
|
||||||
|
// TableFooter
|
||||||
|
export const TableFooter = forwardRef<HTMLTableSectionElement, React.HTMLAttributes<HTMLTableSectionElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<tfoot ref={ref} className={cn('bg-muted/50 font-medium [&>tr]:last:border-b-0', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableFooter.displayName = 'TableFooter';
|
||||||
|
|
||||||
|
// TableCaption
|
||||||
|
export const TableCaption = forwardRef<HTMLTableCaptionElement, React.HTMLAttributes<HTMLTableCaptionElement>>(
|
||||||
|
({ className, ...props }, ref) => (
|
||||||
|
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||||
|
)
|
||||||
|
);
|
||||||
|
TableCaption.displayName = 'TableCaption';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run Test**
|
||||||
|
`npm test packages/ui/src/components/Table/Table.test.tsx`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/Table.tsx packages/ui/src/components/Table/Table.test.tsx
|
||||||
|
git commit -m "feat(ui): add TableHeader, TableBody, TableFooter, TableCaption"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Implement TableRow, TableHead, TableCell
|
||||||
|
|
||||||
|
These components consume `TableContext` to apply correct padding and visual states.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/ui/src/components/Table/Table.tsx`
|
||||||
|
- Modify: `packages/ui/src/components/Table/Table.test.tsx`
|
||||||
|
|
||||||
|
**Step 1: Implement Row and Cells**
|
||||||
|
Append to `packages/ui/src/components/Table/Table.tsx`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useTableContext } from './TableContext';
|
||||||
|
|
||||||
|
// Helper for padding
|
||||||
|
const densityClasses = {
|
||||||
|
s: 'p-2',
|
||||||
|
m: 'p-4',
|
||||||
|
l: 'p-6',
|
||||||
|
};
|
||||||
|
|
||||||
|
// TableRow
|
||||||
|
export const TableRow = forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const { hoverable, striped } = useTableContext();
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors data-[state=selected]:bg-muted',
|
||||||
|
hoverable && 'hover:bg-muted/50',
|
||||||
|
striped && 'even:bg-muted/20', // Minimal stripe effect
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
TableRow.displayName = 'TableRow';
|
||||||
|
|
||||||
|
// TableHead
|
||||||
|
export const TableHead = forwardRef<HTMLTableCellElement, React.ThHTMLAttributes<HTMLTableCellElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const { density } = useTableContext();
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'h-12 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||||
|
densityClasses[density],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
TableHead.displayName = 'TableHead';
|
||||||
|
|
||||||
|
// TableCell
|
||||||
|
export const TableCell = forwardRef<HTMLTableCellElement, React.TdHTMLAttributes<HTMLTableCellElement>>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const { density } = useTableContext();
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'align-middle [&:has([role=checkbox])]:pr-0',
|
||||||
|
densityClasses[density],
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
TableCell.displayName = 'TableCell';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Verify styles in Test**
|
||||||
|
Update `packages/ui/src/components/Table/Table.test.tsx` to check for density classes on cells.
|
||||||
|
|
||||||
|
**Step 3: Run Test**
|
||||||
|
`npm test packages/ui/src/components/Table/Table.test.tsx`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/Table.tsx packages/ui/src/components/Table/Table.test.tsx
|
||||||
|
git commit -m "feat(ui): add TableRow, TableHead, TableCell with Context integration"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Implement Cell (Layout Helper)
|
||||||
|
|
||||||
|
The polymorphic layout primitive.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/ui/src/components/Table/Cell.tsx`
|
||||||
|
- Create: `packages/ui/src/components/Table/Cell.test.tsx`
|
||||||
|
|
||||||
|
**Step 1: Write Test**
|
||||||
|
Create `packages/ui/src/components/Table/Cell.test.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { Cell } from './Cell';
|
||||||
|
|
||||||
|
describe('Cell', () => {
|
||||||
|
it('renders correctly with default props', () => {
|
||||||
|
render(<Cell>Content</Cell>);
|
||||||
|
expect(screen.getByText('Content')).toHaveClass('flex', 'items-center', 'text-left');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies numeric variant styles', () => {
|
||||||
|
render(<Cell variant="numeric">100</Cell>);
|
||||||
|
expect(screen.getByText('100')).toHaveClass('justify-end');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement Cell**
|
||||||
|
Create `packages/ui/src/components/Table/Cell.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { cn } from '@ermnvldmr/stl';
|
||||||
|
|
||||||
|
export interface CellProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||||
|
variant?: 'text' | 'numeric' | 'icon' | 'checkbox';
|
||||||
|
layout?: 'flex' | 'stack';
|
||||||
|
align?: 'start' | 'center' | 'end' | 'between';
|
||||||
|
gap?: 's' | 'm' | 'l';
|
||||||
|
truncate?: boolean;
|
||||||
|
color?: 'default' | 'muted' | 'error' | 'success';
|
||||||
|
}
|
||||||
|
|
||||||
|
const gapClasses = {
|
||||||
|
s: 'gap-1.5',
|
||||||
|
m: 'gap-3',
|
||||||
|
l: 'gap-4',
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorClasses = {
|
||||||
|
default: 'text-foreground',
|
||||||
|
muted: 'text-muted-foreground',
|
||||||
|
error: 'text-error',
|
||||||
|
success: 'text-success',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Cell = forwardRef<HTMLDivElement, CellProps>(
|
||||||
|
({
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
variant = 'text',
|
||||||
|
layout = 'flex',
|
||||||
|
align,
|
||||||
|
gap,
|
||||||
|
truncate = false,
|
||||||
|
color = 'default',
|
||||||
|
...props
|
||||||
|
}, ref) => {
|
||||||
|
|
||||||
|
// Determine default alignment based on variant if not explicitly provided
|
||||||
|
let defaultAlign = 'start';
|
||||||
|
if (variant === 'numeric') defaultAlign = 'end';
|
||||||
|
if (variant === 'icon' || variant === 'checkbox') defaultAlign = 'center';
|
||||||
|
|
||||||
|
const finalAlign = align || defaultAlign;
|
||||||
|
|
||||||
|
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', // Approximation for stack
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
layout === 'flex' ? 'flex flex-row' : 'flex flex-col',
|
||||||
|
layout === 'flex' && 'items-center', // Vertically center flex items by default
|
||||||
|
alignClasses[finalAlign],
|
||||||
|
gap && gapClasses[gap],
|
||||||
|
color && colorClasses[color],
|
||||||
|
truncate && 'truncate',
|
||||||
|
variant === 'numeric' && 'tabular-nums',
|
||||||
|
variant === 'checkbox' && 'w-min mx-auto',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
Cell.displayName = 'Cell';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Run Test**
|
||||||
|
`npm test packages/ui/src/components/Table/Cell.test.tsx`
|
||||||
|
|
||||||
|
**Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/Cell.tsx packages/ui/src/components/Table/Cell.test.tsx
|
||||||
|
git commit -m "feat(ui): add Cell layout primitive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Export and Integration
|
||||||
|
|
||||||
|
Finalize package exports.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `packages/ui/src/components/Table/index.ts`
|
||||||
|
- Modify: `packages/ui/src/components/index.ts`
|
||||||
|
|
||||||
|
**Step 1: Create Table/index.ts**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export * from './Table';
|
||||||
|
export * from './Cell';
|
||||||
|
export * from './types';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Update Main Index**
|
||||||
|
Add `export * from './Table';` to `packages/ui/src/components/index.ts`.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/index.ts packages/ui/src/components/index.ts
|
||||||
|
git commit -m "feat(ui): export Table components"
|
||||||
|
```
|
||||||
@@ -0,0 +1,242 @@
|
|||||||
|
# Rainby Table Components Implementation Plan (Refined)
|
||||||
|
|
||||||
|
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||||
|
|
||||||
|
**Goal:** Implement a comprehensive, modular, and highly controllable Table system adhering to Rainby design standards. This involves a compound component architecture with granular file separation, memoization, and exhaustive testing.
|
||||||
|
|
||||||
|
**Architecture:**
|
||||||
|
|
||||||
|
- **Compound Pattern:** `Table` as the main entry point with sub-components attached (e.g., `Table.Row`).
|
||||||
|
- **Context API:** `TableContext` for global defaults (`density`, `variant`, `striped`).
|
||||||
|
- **Granular Control:** Overrides at row and cell levels (e.g., `padding`, `noBorder`).
|
||||||
|
- **Standards:** `forwardRef` with named functions, `genericMemo` wrapping, and separate files for every sub-component.
|
||||||
|
|
||||||
|
**Tech Stack:** React 19, Tailwind CSS, TypeScript, `@ermnvldmr/stl` (for `cn`, `castRef`, `genericMemo`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Foundation (Types & Context)
|
||||||
|
|
||||||
|
Setup the shared types and the React Context that will drive the table's global styling.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/ui/src/components/Table/types.ts`
|
||||||
|
- Create: `packages/ui/src/components/Table/TableContext.tsx`
|
||||||
|
|
||||||
|
**Step 1: Define Shared Types**
|
||||||
|
Create `packages/ui/src/components/Table/types.ts`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
export type TableDensity = 's' | 'm' | 'l';
|
||||||
|
export type TableVariant = 'surface' | 'outline' | 'ghost';
|
||||||
|
export type TablePadding = 'none' | TableDensity;
|
||||||
|
|
||||||
|
export interface TableContextValue {
|
||||||
|
density: TableDensity;
|
||||||
|
variant: TableVariant;
|
||||||
|
striped: boolean;
|
||||||
|
hoverable: boolean;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Create Context & Hook**
|
||||||
|
Create `packages/ui/src/components/Table/TableContext.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { createContext, useContext } from 'react';
|
||||||
|
import type { TableContextValue } from './types';
|
||||||
|
|
||||||
|
const TableContext = createContext<TableContextValue | null>(null);
|
||||||
|
|
||||||
|
export function useTableContext() {
|
||||||
|
const context = useContext(TableContext);
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('Table sub-components must be used within a Table component.');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const TableProvider = TableContext.Provider;
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/types.ts packages/ui/src/components/Table/TableContext.tsx
|
||||||
|
git commit -m "feat(ui): add Table types and context"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: Implement TableRow (Granular Control)
|
||||||
|
|
||||||
|
Implement the row component with specific overrides for borders and hover states.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/ui/src/components/Table/TableRow/TableRow.tsx`
|
||||||
|
- Create: `packages/ui/src/components/Table/TableRow/TableRow.test.tsx`
|
||||||
|
|
||||||
|
**Step 1: Implement TableRow**
|
||||||
|
Create `packages/ui/src/components/Table/TableRow/TableRow.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { useTableContext } from '../TableContext';
|
||||||
|
|
||||||
|
export interface TableRowProps extends React.HTMLAttributes<HTMLTableRowElement> {
|
||||||
|
noBorder?: boolean;
|
||||||
|
hoverable?: boolean;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TableRowComponent = forwardRef<HTMLTableRowElement, TableRowProps>(
|
||||||
|
function TableRow({ className, children, noBorder, hoverable, selected, ...props }, ref) {
|
||||||
|
const context = useTableContext();
|
||||||
|
const isHoverable = hoverable ?? context.hoverable;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
{...props}
|
||||||
|
ref={castRef<HTMLTableRowElement>(ref)}
|
||||||
|
className={cn(
|
||||||
|
'border-b transition-colors',
|
||||||
|
!noBorder && 'border-border',
|
||||||
|
noBorder && 'border-b-0',
|
||||||
|
isHoverable && 'hover:bg-muted/50',
|
||||||
|
context.striped && 'even:bg-muted/20',
|
||||||
|
selected && 'bg-muted',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TableRow = genericMemo(TableRowComponent);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Write TableRow Test**
|
||||||
|
Create `packages/ui/src/components/Table/TableRow/TableRow.test.tsx`.
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/TableRow/
|
||||||
|
git commit -m "feat(ui): implement TableRow with granular controls"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Implement TableCell & TableHead (Padding Overrides)
|
||||||
|
|
||||||
|
Implement the data and header cells with support for padding overrides.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/ui/src/components/Table/TableCell/TableCell.tsx`
|
||||||
|
- Create: `packages/ui/src/components/Table/TableHead/TableHead.tsx`
|
||||||
|
|
||||||
|
**Step 1: Implement TableCell**
|
||||||
|
Create `packages/ui/src/components/Table/TableCell/TableCell.tsx`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { cn, castRef, genericMemo } from '@ermnvldmr/stl';
|
||||||
|
import React, { forwardRef } from 'react';
|
||||||
|
import { useTableContext } from '../TableContext';
|
||||||
|
import type { TablePadding } from '../types';
|
||||||
|
|
||||||
|
export interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement> {
|
||||||
|
padding?: TablePadding;
|
||||||
|
align?: 'left' | 'center' | 'right';
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddingClasses: Record<TablePadding, string> = {
|
||||||
|
none: 'p-0',
|
||||||
|
s: 'p-2',
|
||||||
|
m: 'p-4',
|
||||||
|
l: 'p-6',
|
||||||
|
};
|
||||||
|
|
||||||
|
const TableCellComponent = forwardRef<HTMLTableCellElement, TableCellProps>(
|
||||||
|
function TableCell({ className, children, padding, align, ...props }, ref) {
|
||||||
|
const { density } = useTableContext();
|
||||||
|
const finalPadding = padding || density;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
{...props}
|
||||||
|
ref={castRef<HTMLTableCellElement>(ref)}
|
||||||
|
className={cn(
|
||||||
|
'align-middle',
|
||||||
|
paddingClasses[finalPadding],
|
||||||
|
align === 'left' && 'text-left',
|
||||||
|
align === 'center' && 'text-center',
|
||||||
|
align === 'right' && 'text-right',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export const TableCell = genericMemo(TableCellComponent);
|
||||||
|
```
|
||||||
|
|
||||||
|
**Step 2: Implement TableHead** (Similar logic to TableCell but for `th`)
|
||||||
|
|
||||||
|
**Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/TableCell/ packages/ui/src/components/Table/TableHead/
|
||||||
|
git commit -m "feat(ui): implement TableCell and TableHead with padding overrides"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: Implement Cell (Layout Primitive)
|
||||||
|
|
||||||
|
Implement the internal layout helper for cell content.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/ui/src/components/Table/Cell/Cell.tsx`
|
||||||
|
|
||||||
|
**Step 1: Implement Cell**
|
||||||
|
Create `packages/ui/src/components/Table/Cell/Cell.tsx`.
|
||||||
|
Follow the Section 3 design (polymorphic variants for numeric, icon, etc.).
|
||||||
|
|
||||||
|
**Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/Cell/
|
||||||
|
git commit -m "feat(ui): implement Cell layout primitive"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Root Component & Assembly
|
||||||
|
|
||||||
|
Finalize the root `Table` component and assemble the compound API.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `packages/ui/src/components/Table/Table.tsx`
|
||||||
|
- Create: `packages/ui/src/components/Table/TableContainer.tsx`
|
||||||
|
- Modify: `packages/ui/src/components/Table/index.ts`
|
||||||
|
|
||||||
|
**Step 1: Assemble Table**
|
||||||
|
In `packages/ui/src/components/Table/Table.tsx`, use `Object.assign` to attach all sub-components.
|
||||||
|
|
||||||
|
**Step 2: Final Commit & Exports**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add packages/ui/src/components/Table/
|
||||||
|
git commit -m "feat(ui): finalize Table compound component assembly"
|
||||||
|
```
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
# Table Component Design
|
||||||
|
|
||||||
|
**Date:** 2026-02-08
|
||||||
|
**Status:** Approved
|
||||||
|
**Topic:** Core Table Primitives for Rainby Design System
|
||||||
|
|
||||||
|
## 1. Overview
|
||||||
|
|
||||||
|
A comprehensive, accessible, and highly composable set of Table components. The goal is to provide a direct counterpart to every native HTML table element, styled according to the Rainby Design System, while offering a specialized `Cell` helper to manage common content layouts (alignment, icons, truncation) without enforcing complex logic.
|
||||||
|
|
||||||
|
## 2. Architecture
|
||||||
|
|
||||||
|
The solution uses a **Context-based** architecture. The root `Table` component accepts high-level style props (`density`, `variant`) and propagates them to all child components via React Context. This avoids prop-drilling and ensures visual consistency.
|
||||||
|
|
||||||
|
### Components
|
||||||
|
|
||||||
|
- **`TableContainer`**: Wrapper for responsive scrolling and outer boundary styling.
|
||||||
|
- **`Table`**: Root element. Manages context.
|
||||||
|
- **`TableHeader`** (`thead`), **`TableBody`** (`tbody`), **`TableFooter`** (`tfoot`): Semantic wrappers.
|
||||||
|
- **`TableRow`** (`tr`): Handles row-level states (hover, striping).
|
||||||
|
- **`TableHead`** (`th`): Header cells.
|
||||||
|
- **`TableCell`** (`td`): Data cells.
|
||||||
|
- **`TableCaption`** (`caption`): Accessible description.
|
||||||
|
- **`Cell`**: **(New)** A polymorphic layout primitive for content _inside_ cells.
|
||||||
|
|
||||||
|
## 3. API & Props
|
||||||
|
|
||||||
|
### 3.1 `Table` (Root)
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
| :------------- | :---------------------------------- | :---------- | :-------------------------------------------- |
|
||||||
|
| `density` | `'s' \| 'm' \| 'l'` | `'m'` | Controls padding of all cells. |
|
||||||
|
| `variant` | `'surface' \| 'outline' \| 'ghost'` | `'surface'` | Controls borders and backgrounds. |
|
||||||
|
| `striped` | `boolean` | `false` | Enables zebra-striping on rows. |
|
||||||
|
| `hoverable` | `boolean` | `false` | Enables hover effects on rows. |
|
||||||
|
| `stickyHeader` | `boolean` | `false` | Fixes the header to the top of the container. |
|
||||||
|
|
||||||
|
### 3.2 `Cell` (Content Helper)
|
||||||
|
|
||||||
|
Used strictly for _layout and styling_ within a `TableCell` or `TableHead`. It does **not** handle sorting logic.
|
||||||
|
|
||||||
|
| Prop | Type | Default | Description |
|
||||||
|
| :--------- | :--------------------------------------------- | :---------- | :--------------------------------- |
|
||||||
|
| `variant` | `'text' \| 'numeric' \| 'icon' \| 'checkbox'` | `'text'` | Presets for alignment and spacing. |
|
||||||
|
| `layout` | `'flex' \| 'stack'` | `'flex'` | Internal flex direction. |
|
||||||
|
| `align` | `'start' \| 'center' \| 'end' \| 'between'` | _(auto)_ | Flex alignment overrides. |
|
||||||
|
| `truncate` | `boolean` | `false` | Forces single-line ellipsis. |
|
||||||
|
| `color` | `'default' \| 'muted' \| 'error' \| 'success'` | `'default'` | Text color token mapping. |
|
||||||
|
|
||||||
|
## 4. Styling Strategy
|
||||||
|
|
||||||
|
### Visual Tokens
|
||||||
|
|
||||||
|
Mappings to Rainby CSS variables:
|
||||||
|
|
||||||
|
- **Backgrounds**: `bg-surface` (default), `bg-muted/50` (headers/striped).
|
||||||
|
- **Borders**: `border-border` (outline), `border-b` only (ghost).
|
||||||
|
- **Text**: `text-foreground` (body), `text-muted-foreground` (headers/icons).
|
||||||
|
|
||||||
|
### Density Map
|
||||||
|
|
||||||
|
- **Small (`s`)**: `px-3 py-2 text-sm`
|
||||||
|
- **Medium (`m`)**: `px-4 py-3 text-sm`
|
||||||
|
- **Large (`l`)**: `px-6 py-4 text-base`
|
||||||
|
|
||||||
|
## 5. Usage Example
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import {
|
||||||
|
TableContainer,
|
||||||
|
Table,
|
||||||
|
TableHeader,
|
||||||
|
TableBody,
|
||||||
|
TableRow,
|
||||||
|
TableHead,
|
||||||
|
TableCell,
|
||||||
|
Cell,
|
||||||
|
} from '@ermnvldmr/ui';
|
||||||
|
|
||||||
|
export const UserList = () => (
|
||||||
|
<TableContainer>
|
||||||
|
<Table density="m" variant="surface" striped hoverable>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>
|
||||||
|
<Cell variant="checkbox">
|
||||||
|
<Checkbox />
|
||||||
|
</Cell>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Cell>User</Cell>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Cell variant="numeric">Revenue</Cell>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
<Cell variant="icon">Actions</Cell>
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<Cell variant="checkbox">
|
||||||
|
<Checkbox />
|
||||||
|
</Cell>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{/* Complex Content Layout */}
|
||||||
|
<Cell layout="flex" gap="m">
|
||||||
|
<Avatar src="..." />
|
||||||
|
<Cell layout="stack" gap="s">
|
||||||
|
<span className="font-medium">Jane Doe</span>
|
||||||
|
<span className="text-muted-foreground text-xs">Admin</span>
|
||||||
|
</Cell>
|
||||||
|
</Cell>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Cell variant="numeric">$1,200.00</Cell>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Cell variant="icon">
|
||||||
|
<Button variant="ghost" size="s" icon="more-horizontal" />
|
||||||
|
</Cell>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</TableContainer>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Implementation Notes
|
||||||
|
|
||||||
|
- **Accessibility**: Ensure `th` elements have `scope="col"` or `scope="row"` automatically where possible, or allow override. `TableCaption` is critical for screen readers.
|
||||||
|
- **Polymorphism**: `TableContainer` should probably just be a `div`, but `Table` components must forward refs to underlying DOM nodes.
|
||||||
|
- **Sorting**: Sorting UI is manual. Users will wrap `Cell` content in a button or place a sort icon next to it manually.
|
||||||
@@ -26,7 +26,9 @@ type Story = StoryObj<typeof Code>;
|
|||||||
// Helper to simulate highlighted lines
|
// Helper to simulate highlighted lines
|
||||||
const MockHighlightedCode = () => (
|
const MockHighlightedCode = () => (
|
||||||
<>
|
<>
|
||||||
<span className="block text-[var(--rb-primary-text)]">import React from 'react';</span>
|
<span className="block text-[var(--rb-primary-text)]">
|
||||||
|
import React from 'react';
|
||||||
|
</span>
|
||||||
<span className="block text-[var(--rb-text)]"> </span>
|
<span className="block text-[var(--rb-text)]"> </span>
|
||||||
<span className="block text-[var(--rb-secondary-text)]">
|
<span className="block text-[var(--rb-secondary-text)]">
|
||||||
export const MyComponent = () => {
|
export const MyComponent = () => {
|
||||||
|
|||||||
@@ -117,8 +117,10 @@ export const LoadingState: Story = {
|
|||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<span className="text-sm font-medium">Simulated Loading (Using Stub)</span>
|
<span className="text-sm font-medium">Simulated Loading (Using Stub)</span>
|
||||||
<Image alt="Loading" ratio="16/9" src="https://invalid-url.jpg" width="300px" />
|
<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 "loading" then "error" state because URL is invalid.</p>
|
<p className="text-xs text-neutral-500">
|
||||||
|
Note: This will stay in "loading" then "error" state because URL is
|
||||||
|
invalid.
|
||||||
|
</p>
|
||||||
</div>
|
</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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -15,6 +15,8 @@ export * from './Blockquote';
|
|||||||
export * from './Stub/Stub';
|
export * from './Stub/Stub';
|
||||||
export * from './Image/Image';
|
export * from './Image/Image';
|
||||||
|
|
||||||
|
export * from './Table';
|
||||||
|
|
||||||
export type { StackProps } from './Stack/Stack';
|
export type { StackProps } from './Stack/Stack';
|
||||||
export type { ListProps, ListVariant, ListSpacing, ListItemProps } from './List/List';
|
export type { ListProps, ListVariant, ListSpacing, ListItemProps } from './List/List';
|
||||||
export type {
|
export type {
|
||||||
@@ -31,3 +33,14 @@ export type { CodeProps } from './Code/Code';
|
|||||||
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
|
export type { BlockquoteProps, BlockquoteCitationProps } from './Blockquote';
|
||||||
export type { StubProps } from './Stub/Stub';
|
export type { StubProps } from './Stub/Stub';
|
||||||
export type { ImageProps } from './Image/Image';
|
export type { ImageProps } from './Image/Image';
|
||||||
|
export type {
|
||||||
|
TableProps,
|
||||||
|
TableDensity,
|
||||||
|
TableVariant,
|
||||||
|
TablePadding,
|
||||||
|
TableRowProps,
|
||||||
|
TableHeadProps,
|
||||||
|
TableCellProps,
|
||||||
|
TableCellBorder,
|
||||||
|
TableContentProps as TableContentCellProps,
|
||||||
|
} from './Table';
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
export /**
|
export /**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const SITE_TITLE = 'Vladimir Eremin';
|
const SITE_TITLE = 'Vladimir Eremin';
|
||||||
export /**
|
export /**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
const SITE_DESCRIPTION = 'Thoughts and creations of mine';
|
const SITE_DESCRIPTION = 'Thoughts and creations of mine';
|
||||||
Reference in new issue
Block a user