diff --git a/packages/demo/src/components/demo/table.tsx b/packages/demo/src/components/demo/table.tsx index 4821a4a..1c83668 100644 --- a/packages/demo/src/components/demo/table.tsx +++ b/packages/demo/src/components/demo/table.tsx @@ -4,7 +4,12 @@ import { EmptyTableState, IconButton, SortButton, - Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableHeader, + TableRow, } from "@eqtylab/equality"; interface TableDemoProps { @@ -14,7 +19,11 @@ interface TableDemoProps { | "with-border" | "with-sorter" | "empty-state" - | "empty-state-custom"; + | "empty-state-custom" + | "column-sizing" + | "truncation" + | "responsive" + | "sticky-header"; elevation?: Elevation; } @@ -22,166 +31,481 @@ export const TableDemo = ({ variant = "default", elevation, }: TableDemoProps) => { - const columns = [ - { key: "name", content: "Name" }, - { key: "email", content: "Email" }, - { key: "role", content: "Role" }, - { key: "status", content: "Status" }, - { key: "actions", content: "" }, - ]; + if (variant === "column-sizing") { + return ( + + + + Name + Email + Role + Status + + + + + + Alice Cooper + alice@example.com + Admin + + Active + + + + + + + Bob Smith + bob@example.com + User + + Active + + + + + + + Charlie Brown + charlie@example.com + Viewer + + Inactive + + + + + + + + ); + } - const rows = [ - { - key: "1", - cells: [ - { key: "name", content: "Alice Cooper" }, - { key: "email", content: "alice@example.com" }, - { key: "role", content: "Admin" }, - { key: "status", content: Active }, - { - key: "actions", - content: , - }, - ], - ...(variant === "clickable" && { - onClick: () => console.log("Clicked row 1"), - }), - }, - { - key: "2", - cells: [ - { key: "name", content: "Bob Smith" }, - { key: "email", content: "bob@example.com" }, - { key: "role", content: "User" }, - { key: "status", content: Active }, - { - key: "actions", - content: , - }, - ], - ...(variant === "clickable" && { - onClick: () => console.log("Clicked row 2"), - }), - }, - { - key: "3", - cells: [ - { key: "name", content: "Charlie Brown" }, - { key: "email", content: "charlie@example.com" }, - { key: "role", content: "Viewer" }, - { - key: "status", - content: Inactive, - }, - { - key: "actions", - content: , - }, - ], - ...(variant === "clickable" && { - onClick: () => console.log("Clicked row 3"), - }), - }, - ]; + if (variant === "truncation") { + return ( + + + + Name + + Email + + Role + Status + + + + + + Alice Cooper + + alice.cooper.very.long.email.address@example.com + + Admin + + Active + + + + + + + Bob Smith + bob@example.com + User + + Active + + + + + + + Charlie Brown + + charlie.brown.another.really.long.address@longdomain.example.com + + Viewer + + Inactive + + + + + + + + ); + } - if (variant === "with-sorter") { - const sortColumns = [ - { - key: "name", - content: ( - {}} - > - Name - - ), - }, - { - key: "email", - content: ( - {}} - > - Email - - ), - }, - { - key: "role", - content: ( - {}} - > - Role - - ), - }, - { - key: "status", - content: ( - {}} - > - Status - - ), - }, - { key: "actions", content: "" }, - ]; + if (variant === "responsive") { + return ( +
+ + + + Name + Email + Role + Status + + + + + + Alice Cooper + + alice@example.com + + Admin + + Active + + + + + + + Bob Smith + + bob@example.com + + User + + Active + + + + + + + Charlie Brown + + charlie@example.com + + Viewer + + Inactive + + + + + + + +
+ ); + } - return ; + if (variant === "sticky-header") { + return ( + + + + Name + Email + Role + Status + + + + + {[ + { + name: "Alice Cooper", + email: "alice@example.com", + role: "Admin", + active: true, + }, + { + name: "Bob Smith", + email: "bob@example.com", + role: "User", + active: true, + }, + { + name: "Charlie Brown", + email: "charlie@example.com", + role: "Viewer", + active: false, + }, + { + name: "Diana Prince", + email: "diana@example.com", + role: "Admin", + active: true, + }, + { + name: "Eve Wilson", + email: "eve@example.com", + role: "User", + active: true, + }, + { + name: "Frank Castle", + email: "frank@example.com", + role: "Viewer", + active: false, + }, + { + name: "Grace Hopper", + email: "grace@example.com", + role: "Admin", + active: true, + }, + { + name: "Hank Pym", + email: "hank@example.com", + role: "User", + active: true, + }, + ].map((user) => ( + + {user.name} + {user.email} + {user.role} + + + {user.active ? "Active" : "Inactive"} + + + + + + + ))} + + + ); + } + + if (variant === "with-sorter") { + return ( + + + + + {}} + > + Name + + + + {}} + > + Email + + + + {}} + > + Role + + + + {}} + > + Status + + + + + + + + Alice Cooper + alice@example.com + Admin + + Active + + + + + + + Bob Smith + bob@example.com + User + + Active + + + + + + + Charlie Brown + charlie@example.com + Viewer + + Inactive + + + + + + + + ); } if (variant === "empty-state") { return ( -
+ className="overflow-hidden rounded-md border" + > + + + Name + Email + Role + Status + + + + + + + No data available + + + + ); } if (variant === "empty-state-custom") { return ( -
{}} - /> - } - /> + className="overflow-hidden rounded-md border" + > + + + Name + Email + Role + Status + + + + + + + {}} + /> + + + + ); } return ( -
+ className={ + variant === "with-border" + ? "overflow-hidden rounded-md border" + : undefined + } + > + + + Name + Email + Role + Status + + + + + console.log("Clicked row 1") + : undefined + } + > + Alice Cooper + alice@example.com + Admin + + Active + + + + + + console.log("Clicked row 2") + : undefined + } + > + Bob Smith + bob@example.com + User + + Active + + + + + + console.log("Clicked row 3") + : undefined + } + > + Charlie Brown + charlie@example.com + Viewer + + Inactive + + + + + + + ); }; diff --git a/packages/demo/src/content/components/table.mdx b/packages/demo/src/content/components/table.mdx index 5e4b682..faf4a80 100644 --- a/packages/demo/src/content/components/table.mdx +++ b/packages/demo/src/content/components/table.mdx @@ -9,64 +9,87 @@ import { ELEVATION } from "@eqtylab/equality"; ## Overview -The Table component displays structured data in rows and columns. It supports clickable rows, sortable column headers, bordered styling, elevation levels, and empty state messaging. Column and cell `content` accepts any `ReactNode`, allowing badges, buttons, and other components inline. +--- + +Tables are built from compositional primitives that map directly to [HTML table elements](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/table). Each primitive is a styled wrapper that accepts all native HTML attributes, giving you full control over layout, sizing, and responsiveness. + +- **TableContainer:** Wraps the `
` in a scrollable container with elevation styling. +- **TableHeader / TableBody / TableFooter:** Semantic section wrappers (``, ``, ``). +- **TableRow:** A table row (``) that accepts `onClick` for clickable rows. +- **TableHead:** A column header cell (`
`) with a `truncate` prop for overflow control. +- **TableCell:** A data cell (``) with a `truncate` prop for overflow control. ## Usage -Import the component: +--- + +Import the components: -```ts -import { Table } from "@eqtylab/equality"; +```tsx +import { + TableContainer, + TableHeader, + TableBody, + TableRow, + TableHead, + TableCell, +} from "@eqtylab/equality"; ``` -Basic usage with required properties: +Basic usage: ```tsx - + + + + Name + Email + + + + + Alice Cooper + alice@example.com + + + ``` ## Variants +--- + ### Default ### Clickable Rows -Rows accept an `onClick` handler, which applies hover and cursor styles. +Rows accept an `onClick` handler, which enables hover interactions. ### Sortable Columns -Use [``](/components/sort-button) in column headers to add interactive sort controls. +Use [``](/components/sort-button) inside `` cells to add interactive sort controls. #### Usage ```tsx -import { SortButton, Table } from "@eqtylab/equality"; - -
+ + + Name - ), - }, - ]} - rows={rows} -/>; + + + +; ``` ### With Border -Apply a border to tables with the `border` prop. This should be added most places `
` is used, except for when it lives within a different container which already has a border applied. +Apply a border to tables by adding `overflow-hidden rounded-md border` to the `TableContainer` className. This should be added most places the table is used, except when it lives within a container that already has a border. ### Empty State -When `rows` is empty and `emptyState` is provided, the table keeps column headers visible and renders the empty state content spanning all columns. +When there are no rows, render empty state content in a `` that spans all columns with `colSpan`. ### Empty State with Custom Component -The `emptyState` prop accepts any `ReactNode`, so you can pass a custom component like `EmptyTableState`. +The empty state cell accepts any `ReactNode`, so you can use a custom component like `EmptyTableState`. +### Sticky Header + +Use the `sticky` prop on `` to keep column headers visible while scrolling. The height of the `` must be constrained for this to work as expected. + + + +#### Usage + +```tsx + + + + Name + Email + + + {/* rows */} + +``` + +## Column Sizing + +--- + +Use the `tableLayout` prop on `TableContainer` to control how column widths are calculated. Set explicit widths on `` cells using the `style` prop. + +### Fixed Layout + +With `tableLayout="fixed"`, columns respect explicit widths exactly. This is the recommended approach when you need predictable column sizing. + + + +#### Usage + +```tsx + + + + Name + Email + Role + Status + + + + +``` + +### Min and Max Width + +Use `style={{ minWidth }}` or `style={{ maxWidth }}` on `` to constrain column sizes. `minWidth` works in both `auto` and `fixed` layouts. `maxWidth` works best with the default `auto` layout. + +```tsx +{ + /* Column won't shrink below 150px */ +} +Description; + +{ + /* Column won't grow beyond 300px — pair with truncate */ +} + + Email +; +``` + +### Shrink to Content + +In the default `auto` layout, use `style={{ width: "1%" }}` to minimize a column to fit its content. The browser's table algorithm ensures the column still renders at least as wide as its content, while giving all remaining space to other columns. This is useful for action columns or icon-only columns. + +```tsx +{/* Actions */} +``` + +## Truncation + +--- + +Use the `truncate` prop on `` and `` to clip overflowing text with an ellipsis. This works best with `tableLayout="fixed"` and an explicit column width so the cell has a defined boundary to truncate against. + + + +#### Usage + +```tsx + + + + Name + + Email + + + + + + Alice Cooper + alice.cooper.very.long.email@example.com + + + +``` + +## Responsive Columns + +--- + +Use [container queries](https://tailwindcss.com/docs/responsive-design#what-are-container-queries) to show or hide columns based on the table's container width. Wrap the table in a `@container` element and apply `hidden @md:table-cell` (or similar) to both the `` and `` for columns that should collapse. + + + +#### Usage + +```tsx +
+ + + + Name + Email + Role + Status + + + + + Alice Cooper + + alice@example.com + + Admin + + Active + + + + +
+``` + ## Elevations +--- + ### Sunken @@ -120,10 +284,35 @@ The `emptyState` prop accepts any `ReactNode`, so you can pass a custom componen ## Props -| Name | Description | Type | Default | Required | -| ------------ | ------------------------------------------------------------ | ------------------------------------- | ------- | -------- | -| `columns` | Column definitions with key, content, and optional className | `TableColumn[]` | | ✅ | -| `rows` | Row data with key, cells, and optional onClick/className | `TableRowData[]` | | ✅ | -| `border` | Adds a border and rounded corners around the table | `boolean` | `false` | ❌ | -| `elevation` | Controls the shadow and border elevation level | `sunken`, `base`, `raised`, `overlay` | `base` | ❌ | -| `emptyState` | Content rendered when rows is empty, spanning all columns | `ReactNode` | | ❌ | +--- + +### TableContainer + +| Name | Description | Type | Default | Required | +| ------------- | ------------------------------------------ | ------------------------------------- | ------- | -------- | +| `elevation` | Controls the shadow and background styling | `sunken`, `base`, `raised`, `overlay` | `base` | ❌ | +| `tableLayout` | Controls the CSS table-layout algorithm | `auto`, `fixed` | `auto` | ❌ | + +### TableHeader + +| Name | Description | Type | Default | Required | +| -------- | ------------------------------------------------ | --------- | ------- | -------- | +| `sticky` | Keeps the header visible while the table scrolls | `boolean` | `false` | ❌ | + +### TableRow + +| Name | Description | Type | Default | Required | +| ----------- | ------------------------------------------- | --------- | ------- | -------- | +| `clickable` | Applies hover and cursor interaction styles | `boolean` | `false` | ❌ | + +### TableHead + +| Name | Description | Type | Default | Required | +| ---------- | ------------------------------------------ | --------- | ------- | -------- | +| `truncate` | Clips overflowing content with an ellipsis | `boolean` | `false` | ❌ | + +### TableCell + +| Name | Description | Type | Default | Required | +| ---------- | ------------------------------------------ | --------- | ------- | -------- | +| `truncate` | Clips overflowing content with an ellipsis | `boolean` | `false` | ❌ | diff --git a/packages/ui/package.json b/packages/ui/package.json index 964112b..daf91ff 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -2,7 +2,7 @@ "name": "@eqtylab/equality", "description": "EQTYLab's component and token-based design system", "homepage": "https://equality.eqtylab.io/", - "version": "1.4.1", + "version": "1.5.0", "license": "Apache-2.0", "keywords": [ "component library", diff --git a/packages/ui/src/components/badge/badge.module.css b/packages/ui/src/components/badge/badge.module.css index 6c722b2..92dc3ea 100644 --- a/packages/ui/src/components/badge/badge.module.css +++ b/packages/ui/src/components/badge/badge.module.css @@ -5,6 +5,7 @@ @apply rounded-full; @apply flex items-center; @apply w-max; + @apply tabular-nums; } /* Size Variants */ diff --git a/packages/ui/src/components/table/table-components.module.css b/packages/ui/src/components/table/table-components.module.css index ec303d5..f052c32 100644 --- a/packages/ui/src/components/table/table-components.module.css +++ b/packages/ui/src/components/table/table-components.module.css @@ -13,6 +13,10 @@ @apply [&_tr]:border-b; } +.table-header--sticky { + @apply sticky top-0 z-10; +} + .table-body { @apply [&_tr:last-child]:border-0; } @@ -35,6 +39,24 @@ @apply [&:has([role=checkbox])]:pr-0; } +/* Clickable Variant */ + +.table-row--clickable { + --mix-color: var(--color-brand-primary); + --hover-darken: 50%; + --hover-lighten: 50%; + @apply cursor-pointer; + @apply hover:bg-mixed-light! dark:hover:bg-mixed-dark!; + @apply data-[state=selected]:bg-mixed-light dark:data-[state=selected]:bg-mixed-dark; +} + +/* Truncate Variants */ + +.table-head--truncate, +.table-cell--truncate { + @apply max-w-0 overflow-hidden text-ellipsis whitespace-nowrap; +} + .table-caption { @apply text-text-secondary mt-4 text-sm; } diff --git a/packages/ui/src/components/table/table-components.tsx b/packages/ui/src/components/table/table-components.tsx index def0c2c..3c5656f 100644 --- a/packages/ui/src/components/table/table-components.tsx +++ b/packages/ui/src/components/table/table-components.tsx @@ -9,19 +9,32 @@ const tableElevationVariants = generateElevationVariants(styles, 'table', ELEVAT const TableContainer = React.forwardRef< HTMLTableElement, - React.HTMLAttributes & VariantProps ->(({ className, elevation = ELEVATION.RAISED, ...props }, ref) => ( -
-
+ React.HTMLAttributes & + VariantProps & { tableLayout?: 'auto' | 'fixed' } +>(({ className, style, elevation = ELEVATION.RAISED, tableLayout, ...props }, ref) => ( +
+
)); TableContainer.displayName = 'Table'; const TableHeader = React.forwardRef< HTMLTableSectionElement, - React.HTMLAttributes ->(({ className, ...props }, ref) => ( - + React.HTMLAttributes & { sticky?: boolean } +>(({ className, sticky, ...props }, ref) => ( + )); TableHeader.displayName = 'TableHeader'; @@ -41,26 +54,39 @@ const TableFooter = React.forwardRef< )); TableFooter.displayName = 'TableFooter'; -const TableRow = React.forwardRef>( - ({ className, ...props }, ref) => ( - - ) -); +const TableRow = React.forwardRef< + HTMLTableRowElement, + React.HTMLAttributes & { clickable?: boolean } +>(({ className, clickable, ...props }, ref) => ( + +)); TableRow.displayName = 'TableRow'; const TableHead = React.forwardRef< HTMLTableCellElement, - React.ThHTMLAttributes ->(({ className, ...props }, ref) => ( -
+ React.ThHTMLAttributes & { truncate?: boolean } +>(({ className, truncate, ...props }, ref) => ( + )); TableHead.displayName = 'TableHead'; const TableCell = React.forwardRef< HTMLTableCellElement, - React.TdHTMLAttributes ->(({ className, ...props }, ref) => ( - + React.TdHTMLAttributes & { truncate?: boolean } +>(({ className, truncate, ...props }, ref) => ( + )); TableCell.displayName = 'TableCell'; diff --git a/packages/ui/src/components/table/table.module.css b/packages/ui/src/components/table/table.module.css index a8f9155..aa05c79 100644 --- a/packages/ui/src/components/table/table.module.css +++ b/packages/ui/src/components/table/table.module.css @@ -4,15 +4,6 @@ @apply shadow-sm; } -.table-row-clickable { - --mix-color: var(--color-brand-primary); - --hover-darken: 50%; - --hover-lighten: 50%; - @apply cursor-pointer; - @apply hover:bg-mixed-light! dark:hover:bg-mixed-dark!; - @apply data-[state=selected]:bg-mixed-light dark:data-[state=selected]:bg-mixed-dark; -} - .table-border { @apply border; @apply overflow-hidden rounded-md; diff --git a/packages/ui/src/components/table/table.tsx b/packages/ui/src/components/table/table.tsx index 58fd486..63e224c 100644 --- a/packages/ui/src/components/table/table.tsx +++ b/packages/ui/src/components/table/table.tsx @@ -39,6 +39,7 @@ interface TableProps extends VariantProps { className?: string; border?: boolean; emptyState?: React.ReactNode; + tableLayout?: 'auto' | 'fixed'; } const tableElevationVariants = generateElevationVariants(styles, 'table', ELEVATION.BASE); @@ -50,6 +51,7 @@ const Table = ({ border = false, elevation = ELEVATION.BASE, emptyState, + tableLayout, }: TableProps) => { const isEmpty = rows.length === 0; @@ -62,7 +64,7 @@ const Table = ({ className )} > - + {columns.map((column) => ( @@ -85,7 +87,8 @@ const Table = ({ {rows.map((row) => ( {row.cells.map((cell) => (