diff --git a/.changeset/bright-cells-shine.md b/.changeset/bright-cells-shine.md new file mode 100644 index 00000000..4c4c7ea8 --- /dev/null +++ b/.changeset/bright-cells-shine.md @@ -0,0 +1,29 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `cellProps(row)`, `headerProps(ctx)`, and `footerProps(ctx)` callbacks to DataTable columns to set `className`, `style`, and `title` on the entire data/header/footer cell, including padding. Each can return `undefined` to keep the defaults; `headerProps` receives the same label and sort context as `header`, and `footerProps` receives the same current-page `rows` as `footer`. +Inline body-cell backgrounds remain visible on hover and selection, including in pinned columns. Footer styling is skipped during loading and errors and for hidden columns; it does not create a footer row by itself. Content, sorting, and context menus keep their existing behavior; existing tables are unchanged. + +```tsx +column({ + id: "hours", + type: "number", + headerProps: () => ({ + style: { backgroundColor: "var(--muted)" }, + title: "Monthly overtime hours", + }), + footer: ({ rows }) => rows.reduce((sum, row) => sum + row.hours, 0), + footerProps: ({ rows }) => ({ + style: { backgroundColor: "var(--muted)" }, + title: `Total overtime for ${rows.length} rows on this page`, + }), + cellProps: (row) => + row.hours >= 60 + ? { + style: { backgroundColor: "var(--danger-solid)", color: "var(--danger-contrast)" }, + title: "Overtime threshold exceeded", + } + : undefined, +}); +``` diff --git a/docs-manifest.json b/docs-manifest.json index de1e4925..8ed7709e 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -473,10 +473,10 @@ ], "hashes": { "typeSurface": "33eb2bdd6be1c774", - "outline": "8ca624c7ec761939", + "outline": "b3b98ba3cd0bd4a5", "snapshot": "a97e067c66e46ef6", - "outputMd": "b1ee3ccb6cb4a1f4", - "examples": "10abd548e6affb9d" + "outputMd": "640e1060814bd7e9", + "examples": "c7f4624e218ed59f" } }, "date-picker": { @@ -1787,7 +1787,7 @@ "packages/core/skills/app-shell-patterns/references/components/combobox.md": "dfb775c7c4307409", "packages/core/skills/app-shell-patterns/references/components/command-palette.md": "7e5bc675a593791a", "packages/core/skills/app-shell-patterns/references/components/csv-importer.md": "53d3c795ca21b9dc", - "packages/core/skills/app-shell-patterns/references/components/data-table.md": "b1ee3ccb6cb4a1f4", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "640e1060814bd7e9", "packages/core/skills/app-shell-patterns/references/components/date-picker.md": "bc69f14829004f9d", "packages/core/skills/app-shell-patterns/references/components/default-header.md": "375e55f4b12fa2be", "packages/core/skills/app-shell-patterns/references/components/default-sidebar.md": "3422388a0cc78571", diff --git a/docs-src/components/data-table.docs.examples.tsx b/docs-src/components/data-table.docs.examples.tsx index 382e7cdc..1e781489 100644 --- a/docs-src/components/data-table.docs.examples.tsx +++ b/docs-src/components/data-table.docs.examples.tsx @@ -7,6 +7,67 @@ import { useDataTable, } from "@tailor-platform/app-shell"; +export function PerCellStyling() { + type Overtime = { id: string; employee: string; january: number; february: number }; + const { column } = createColumnHelper(); + const overtimeStyle = (hours: number) => { + const tone = hours >= 60 ? "danger" : hours >= 40 ? "warning" : "success"; + return { + backgroundColor: `var(--${tone}-solid)`, + color: `var(--${tone}-contrast)`, + }; + }; + const table = useDataTable({ + data: { + rows: [ + { id: "1", employee: "Alex", january: 24, february: 48 }, + { id: "2", employee: "Sam", january: 45, february: 64 }, + { id: "3", employee: "Robin", january: 62, february: 32 }, + ], + }, + columns: [ + column({ id: "employee", label: "Employee", type: "text", width: 160 }), + column({ + id: "january", + label: "January (hours)", + type: "number", + width: 180, + cellProps: (row) => ({ + style: overtimeStyle(row.january), + title: `${row.january} hours of overtime`, + }), + }), + column({ + id: "february", + label: "February (hours)", + type: "number", + width: 180, + pin: "right", + headerProps: () => ({ + className: "astw:whitespace-nowrap", + style: { backgroundColor: "var(--muted)" }, + title: "February overtime summary", + }), + cellProps: (row) => ({ + className: "astw:tabular-nums", + style: overtimeStyle(row.february), + title: `${row.february} hours of overtime`, + }), + }), + ], + rowSelection: {}, + }); + + return ( +
+

Overtime: below 40 hours is safe, 40–59 is caution, 60 or more is danger.

+ + + +
+ ); +} + export function ColumnFooters() { type Line = { id: string; item: string; quantity: number | null }; const [rows, setRows] = useState([ @@ -34,6 +95,17 @@ export function ColumnFooters() { ), }, footer: aggregations.sum((row) => row.quantity), + footerProps: ({ rows }) => + rows.length > 0 + ? { + className: "tabular-nums", + style: { + backgroundColor: "var(--success-solid)", + color: "var(--success-contrast)", + }, + title: `Quantity total for ${rows.length} items on this page`, + } + : undefined, }), ], }); diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 63ee9f41..b4aecbc7 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -43,6 +43,7 @@ import { type DateCellEditOptions, type CellEditState, type DateTimeDraft, + type FooterRenderContext, } from "@tailor-platform/app-shell"; ``` @@ -268,11 +269,27 @@ const table = useDataTable({ ; ``` +## Per-cell styling + +Use `cellProps(row)` to set `className`, `style`, and `title` on the actual data-cell ``, including its padding. Return `undefined` to keep the defaults. This works with built-in renderers, custom `render`, truncation tooltips, and editable cells. It does not customize headers, footers, loading placeholders, empty/error states, or the built-in selection, expansion, and action cells. Content stays owned by `render`; events, refs, and cell spanning are not supported through `cellProps`. + +An explicit `style.backgroundColor` stays visible during row hover and selection, for both scrollable and pinned columns. Cells returning `undefined` retain the default hover/selection backgrounds; checkboxes and row selection semantics are unchanged. Classes are merged after built-in classes, but CSS specificity still applies: pinned cells have their own group-hover and group-aria-selected backgrounds, which a plain background class does not override. Use `style.backgroundColor` when an informational background must remain visible in every state. Keep the value or another non-color cue visible so color is not the only way to understand the cell. + +Column `width` and the pinned cell's sticky position/edge offset take precedence over `cellProps.style`. Use this hook for presentation rather than layout overrides. Pinned backgrounds must be opaque to hide scrolling content; the example uses existing solid/contrast token pairs rather than translucent surface tokens. + +### Header styling + +Use `headerProps(ctx)` to return `className`, `style`, and/or `title` to customize the actual ``, including its padding. Like `cellProps(row)`, return `undefined` to keep the defaults. It receives the same `HeaderRenderContext` as `header(ctx)`: `label` and `sortable`, plus `sortDirection` and `activateSort()` when sortable. It works with the default header or a custom `header(ctx)` renderer; styling alone does not replace the built-in sort button or context menu. + +Classes are merged after built-in classes, and column `width` and sticky position/edge offset take precedence over the returned `style`, just as for body cells. Use opaque backgrounds for pinned headers. Events, refs, content, and cell spanning are not supported through `headerProps`; built-in selection, expansion, and action headers are unaffected. + + + ## Column footers Set `column.footer` to a static string such as `footer: "Total"` or a renderer to show content beneath that column without adding synthetic rows to `data.rows`. The [`aggregations`](../api/aggregations.md) helpers create ordinary footer renderers from typed selectors. `DataTable.Table` automatically renders one `` row when at least one visible column defines a footer, including an empty string. Fixed header text still uses `column.label`; `column.header` remains a custom renderer that replaces the built-in header UI. - + - **Current rows, not all records.** `ctx.rows` is the current `data.rows` array exposed as read-only, so a paginated table computes a page total. Selection does not narrow it. For a server-calculated total across all pages, use a closure such as `footer: () => totalQuantity` instead. - **Current adopted values.** Updating `data.rows`, including adopting a confirmed cell edit, updates the footer. Unconfirmed editor drafts are not included, and footer callbacks must not mutate rows. @@ -282,6 +299,14 @@ Set `column.footer` to a static string such as `footer: "Total"` or a renderer t - **Not data rows.** The footer has no generated editors, selection checkboxes, expansion triggers, or row-action menus; their utility columns have empty footer cells to preserve alignment. Footer cells do not trigger `onClickRow`. - **Loading and empty states.** Loading keeps the footer row mounted and replaces defined footer content with skeletons without calling footer renderers, including during page changes. This preserves the footer region without showing stale totals or calculating from incomplete data. Errors hide the footer. Loaded empty data still calls visible footer renderers with `rows: []`; the consumer chooses whether to return zero, a dash, or `null` for a blank cell. The table does not substitute these values. Check `rows.length` to distinguish no records from records whose total is zero. If no visible column defines `footer`, no `` is rendered. +### Footer styling + +Use `footerProps(ctx)` to return `className`, `style`, and/or `title` for the actual footer ``, including its padding. It receives the same `FooterRenderContext` as `footer(ctx)`, with the current page's read-only `rows`. Return `undefined` to keep the defaults. It works with static strings, custom footer renderers, and blank footer cells when another visible column defines `footer`; `footerProps` alone does not create a footer row. + +Classes are merged after built-in classes; column `width` and sticky position/edge offset take precedence over the returned `style`. Use opaque backgrounds for pinned footer cells. Content stays owned by `footer`; events, refs, and cell spanning are not supported. Built-in selection, expansion, and action footer cells and the separate `DataTable.Footer` container are unaffected. + +Footer styling callbacks are not called while loading, on errors, or for hidden columns. Loaded empty data calls them with `rows: []`, and styles are recomputed when `data.rows` changes. + ### Aggregation helpers Import [`aggregations`](../api/aggregations.md) and pass a required row selector to `.sum()`, `.avg()`, `.min()`, `.max()`, or `.count()`. For example, `footer: aggregations.sum((row) => row.quantity)` returns an ordinary footer renderer that selects and sums quantities from the current `ctx.rows`. Inline selectors infer the row type from the surrounding column definition. The same callback can be invoked outside DataTable with `{ rows }`. @@ -331,7 +356,7 @@ These APIs use “footer” at different scopes: `DataTable` treats column configuration as two layers: -- **Presentation** — `column.header`, `column.render`, and `column.footer` control what is rendered. +- **Presentation** — `column.header`, `column.render`, and `column.footer` control rendered content; `column.headerProps`, `column.cellProps`, and `column.footerProps` customize the surrounding header, data-cell, and footer elements. - **Behavior/data** — `column.label`, `column.id`, `column.accessor`, `column.sort`, `column.filter`, and collection `control` drive built-in behavior such as sorting, truncation tooltips, persistence, and context-menu actions. This means built-in interactions do **not** infer meaning from rendered DOM content. If a custom-rendered column should still participate in built-in copy/filter/tooltip behavior, give it an `id` or `accessor` that exposes the raw value. @@ -767,20 +792,23 @@ A column definition passed to `useDataTable`. `Column` is a discriminated ### Shared fields -| Property | Type | Description | -| ---------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `label` | `string` | Column header text. Omit for icon-only columns. | -| `header` | `(ctx: HeaderRenderContext) => ReactNode` | Custom header renderer. Presentation only. When omitted, the built-in header renders `label` and owns the sort button/indicator. When provided, the return value replaces the built-in header entirely; sortable custom headers receive `sortDirection` and `activateSort()` via `ctx` and must render their own click surface for left-click sorting. The header context menu still uses the column metadata (`label`, `sort`, etc.). | -| `footer` | `string \| ((ctx: FooterRenderContext) => ReactNode)` | Optional static string or column footer renderer, including callbacks created by `aggregations`. Renderers receive current `data.rows` (the current page, not selected rows or all records). Enables the automatic sticky `` row; no default content or typed formatting is applied. See [Column footers](#column-footers). | -| `render` | `(row: TRow) => ReactNode` | Renders the cell content. Presentation only. Optional — overrides the built-in `type` display renderer when set, not an editor configured through `edit`. Leave `edit` unset to draw your own editor with `render`. Built-in behaviors that need the raw value (truncate tooltip, cell copy/filter context-menu actions) still resolve it from `accessor` first, then `row[col.id]`, and only fall back to the return value of `render(row)` when it is a primitive (`string`, `number`, `boolean`, or `bigint`). | -| `id` | `string` | Stable identifier for column visibility, persisted layout state, raw-value fallback (`row[col.id]`), and the React key. Falls back to `label` when omitted. Set this explicitly when `label` is absent, not unique, or when a custom-rendered column should still participate in built-in cell behaviors that need a raw value. | -| `width` | `number` | Fixed column width in pixels. Optional. | -| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via the toolbar's column-settings control). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection and expand columns auto-pin left and the row-actions column auto-pin right. | -| `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. | -| `truncate` | `boolean` | Truncate overflowing text with an ellipsis. Wires up an app-shell `` automatically when the resolved raw cell value is a string or number (`accessor` first, then `row[col.id]`). With `inferColumns`, no explicit `accessor` is needed because `id` is pinned to the field name. Requires another column to anchor the row width (`width` on a neighbor, or a fixed-size column like selection / row actions). | -| `accessor` | _(narrowed per `type`)_ | Extracts the raw value. This is the primary source of truth for built-in behaviors that need a value independent of presentation (typed rendering, truncate tooltip, cell copy/filter context-menu actions). The return type is narrowed per `type` branch — returning an array is a compile error on all typed columns except `badge`, and returning a plain object is a compile error on all typed columns. Untyped columns (`type` omitted) retain `unknown`. `null` and `undefined` are always allowed. | -| `sort` | `SortConfig` | Sort configuration. When set, the column participates in the built-in sort behaviors: the default clickable header and the header context-menu sort submenu. Custom headers still need to call `ctx.activateSort()` to opt into left-click sorting. | -| `filter` | `FilterConfig` | Filter configuration. When set, the column appears as an option in `DataTable.Filters` and can also drive the cell **Add filter** context-menu submenu when collection `control` is available. The cell menu narrows this to single-value operators only. | +| Property | Type | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `label` | `string` | Column header text. Omit for icon-only columns. | +| `header` | `(ctx: HeaderRenderContext) => ReactNode` | Custom header renderer. Presentation only. When omitted, the built-in header renders `label` and owns the sort button/indicator. When provided, the return value replaces the built-in header entirely; sortable custom headers receive `sortDirection` and `activateSort()` via `ctx` and must render their own click surface for left-click sorting. The header context menu still uses the column metadata (`label`, `sort`, etc.). | +| `footer` | `string \| ((ctx: FooterRenderContext) => ReactNode)` | Optional static string or column footer renderer, including callbacks created by `aggregations`. Renderers receive current `data.rows` (the current page, not selected rows or all records). Enables the automatic sticky `` row; no default content or typed formatting is applied. See [Column footers](#column-footers). | +| `render` | `(row: TRow) => ReactNode` | Renders the cell content. Presentation only. Optional — overrides the built-in `type` display renderer when set, not an editor configured through `edit`. Leave `edit` unset to draw your own editor with `render`. Built-in behaviors that need the raw value (truncate tooltip, cell copy/filter context-menu actions) still resolve it from `accessor` first, then `row[col.id]`, and only fall back to the return value of `render(row)` when it is a primitive (`string`, `number`, `boolean`, or `bigint`). | +| `id` | `string` | Stable identifier for column visibility, persisted layout state, raw-value fallback (`row[col.id]`), and the React key. Falls back to `label` when omitted. Set this explicitly when `label` is absent, not unique, or when a custom-rendered column should still participate in built-in cell behaviors that need a raw value. | +| `headerProps` | `(ctx: HeaderRenderContext) => Pick, "className" \| "style" \| "title"> \| undefined` | Sets classes, inline styles, and a native title on this column's `` based on the header context. Return `undefined` to keep the defaults. Does not replace header content or sorting. Column width and sticky positioning take precedence over inline styles. See [Header styling](#header-styling). | +| `cellProps` | `(row: TRow) => Pick, "className" \| "style" \| "title"> \| undefined` | Sets classes, inline styles, and a native title on the data-cell ``. Column width and sticky positioning take precedence over inline styles. See [Per-cell styling](#per-cell-styling). | +| `footerProps` | `(ctx: FooterRenderContext) => Pick, "className" \| "style" \| "title"> \| undefined` | Sets classes, inline styles, and a native title on this column's footer `` based on current rows. Return `undefined` to keep the defaults. Does not create a footer row or replace its content. Column width and sticky positioning take precedence. See [Footer styling](#footer-styling). | +| `width` | `number` | Fixed column width in pixels. Optional. | +| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via the toolbar's column-settings control). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection and expand columns auto-pin left and the row-actions column auto-pin right. | +| `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. | +| `truncate` | `boolean` | Truncate overflowing text with an ellipsis. Wires up an app-shell `` automatically when the resolved raw cell value is a string or number (`accessor` first, then `row[col.id]`). With `inferColumns`, no explicit `accessor` is needed because `id` is pinned to the field name. Requires another column to anchor the row width (`width` on a neighbor, or a fixed-size column like selection / row actions). | +| `accessor` | _(narrowed per `type`)_ | Extracts the raw value. This is the primary source of truth for built-in behaviors that need a value independent of presentation (typed rendering, truncate tooltip, cell copy/filter context-menu actions). The return type is narrowed per `type` branch — returning an array is a compile error on all typed columns except `badge`, and returning a plain object is a compile error on all typed columns. Untyped columns (`type` omitted) retain `unknown`. `null` and `undefined` are always allowed. | +| `sort` | `SortConfig` | Sort configuration. When set, the column participates in the built-in sort behaviors: the default clickable header and the header context-menu sort submenu. Custom headers still need to call `ctx.activateSort()` to opt into left-click sorting. | +| `filter` | `FilterConfig` | Filter configuration. When set, the column appears as an option in `DataTable.Filters` and can also drive the cell **Add filter** context-menu submenu when collection `control` is available. The cell menu narrows this to single-value operators only. | ### `type`-specific fields diff --git a/docs/components/data-table.md b/docs/components/data-table.md index 8b20473c..f91ef709 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -37,6 +37,7 @@ import { type DateCellEditOptions, type CellEditState, type DateTimeDraft, + type FooterRenderContext, } from "@tailor-platform/app-shell"; ``` @@ -262,6 +263,85 @@ const table = useDataTable({ ; ``` +## Per-cell styling + +Use `cellProps(row)` to set `className`, `style`, and `title` on the actual data-cell ``, including its padding. Return `undefined` to keep the defaults. This works with built-in renderers, custom `render`, truncation tooltips, and editable cells. It does not customize headers, footers, loading placeholders, empty/error states, or the built-in selection, expansion, and action cells. Content stays owned by `render`; events, refs, and cell spanning are not supported through `cellProps`. + +An explicit `style.backgroundColor` stays visible during row hover and selection, for both scrollable and pinned columns. Cells returning `undefined` retain the default hover/selection backgrounds; checkboxes and row selection semantics are unchanged. Classes are merged after built-in classes, but CSS specificity still applies: pinned cells have their own group-hover and group-aria-selected backgrounds, which a plain background class does not override. Use `style.backgroundColor` when an informational background must remain visible in every state. Keep the value or another non-color cue visible so color is not the only way to understand the cell. + +Column `width` and the pinned cell's sticky position/edge offset take precedence over `cellProps.style`. Use this hook for presentation rather than layout overrides. Pinned backgrounds must be opaque to hide scrolling content; the example uses existing solid/contrast token pairs rather than translucent surface tokens. + +### Header styling + +Use `headerProps(ctx)` to return `className`, `style`, and/or `title` to customize the actual ``, including its padding. Like `cellProps(row)`, return `undefined` to keep the defaults. It receives the same `HeaderRenderContext` as `header(ctx)`: `label` and `sortable`, plus `sortDirection` and `activateSort()` when sortable. It works with the default header or a custom `header(ctx)` renderer; styling alone does not replace the built-in sort button or context menu. + +Classes are merged after built-in classes, and column `width` and sticky position/edge offset take precedence over the returned `style`, just as for body cells. Use opaque backgrounds for pinned headers. Events, refs, content, and cell spanning are not supported through `headerProps`; built-in selection, expansion, and action headers are unaffected. + + + +```tsx +function PerCellStyling() { + type Overtime = { id: string; employee: string; january: number; february: number }; + const { column } = createColumnHelper(); + const overtimeStyle = (hours: number) => { + const tone = hours >= 60 ? "danger" : hours >= 40 ? "warning" : "success"; + return { + backgroundColor: `var(--${tone}-solid)`, + color: `var(--${tone}-contrast)`, + }; + }; + const table = useDataTable({ + data: { + rows: [ + { id: "1", employee: "Alex", january: 24, february: 48 }, + { id: "2", employee: "Sam", january: 45, february: 64 }, + { id: "3", employee: "Robin", january: 62, february: 32 }, + ], + }, + columns: [ + column({ id: "employee", label: "Employee", type: "text", width: 160 }), + column({ + id: "january", + label: "January (hours)", + type: "number", + width: 180, + cellProps: (row) => ({ + style: overtimeStyle(row.january), + title: `${row.january} hours of overtime`, + }), + }), + column({ + id: "february", + label: "February (hours)", + type: "number", + width: 180, + pin: "right", + headerProps: () => ({ + className: "astw:whitespace-nowrap", + style: { backgroundColor: "var(--muted)" }, + title: "February overtime summary", + }), + cellProps: (row) => ({ + className: "astw:tabular-nums", + style: overtimeStyle(row.february), + title: `${row.february} hours of overtime`, + }), + }), + ], + rowSelection: {}, + }); + + return ( +
+

Overtime: below 40 hours is safe, 40–59 is caution, 60 or more is danger.

+ + + +
+ ); +} +``` + ## Column footers Set `column.footer` to a static string such as `footer: "Total"` or a renderer to show content beneath that column without adding synthetic rows to `data.rows`. The [`aggregations`](../api/aggregations.md) helpers create ordinary footer renderers from typed selectors. `DataTable.Table` automatically renders one `` row when at least one visible column defines a footer, including an empty string. Fixed header text still uses `column.label`; `column.header` remains a custom renderer that replaces the built-in header UI. @@ -296,6 +376,17 @@ function ColumnFooters() { ), }, footer: aggregations.sum((row) => row.quantity), + footerProps: ({ rows }) => + rows.length > 0 + ? { + className: "tabular-nums", + style: { + backgroundColor: "var(--success-solid)", + color: "var(--success-contrast)", + }, + title: `Quantity total for ${rows.length} items on this page`, + } + : undefined, }), ], }); @@ -316,6 +407,14 @@ function ColumnFooters() { - **Not data rows.** The footer has no generated editors, selection checkboxes, expansion triggers, or row-action menus; their utility columns have empty footer cells to preserve alignment. Footer cells do not trigger `onClickRow`. - **Loading and empty states.** Loading keeps the footer row mounted and replaces defined footer content with skeletons without calling footer renderers, including during page changes. This preserves the footer region without showing stale totals or calculating from incomplete data. Errors hide the footer. Loaded empty data still calls visible footer renderers with `rows: []`; the consumer chooses whether to return zero, a dash, or `null` for a blank cell. The table does not substitute these values. Check `rows.length` to distinguish no records from records whose total is zero. If no visible column defines `footer`, no `` is rendered. +### Footer styling + +Use `footerProps(ctx)` to return `className`, `style`, and/or `title` for the actual footer ``, including its padding. It receives the same `FooterRenderContext` as `footer(ctx)`, with the current page's read-only `rows`. Return `undefined` to keep the defaults. It works with static strings, custom footer renderers, and blank footer cells when another visible column defines `footer`; `footerProps` alone does not create a footer row. + +Classes are merged after built-in classes; column `width` and sticky position/edge offset take precedence over the returned `style`. Use opaque backgrounds for pinned footer cells. Content stays owned by `footer`; events, refs, and cell spanning are not supported. Built-in selection, expansion, and action footer cells and the separate `DataTable.Footer` container are unaffected. + +Footer styling callbacks are not called while loading, on errors, or for hidden columns. Loaded empty data calls them with `rows: []`, and styles are recomputed when `data.rows` changes. + ### Aggregation helpers Import [`aggregations`](../api/aggregations.md) and pass a required row selector to `.sum()`, `.avg()`, `.min()`, `.max()`, or `.count()`. For example, `footer: aggregations.sum((row) => row.quantity)` returns an ordinary footer renderer that selects and sums quantities from the current `ctx.rows`. Inline selectors infer the row type from the surrounding column definition. The same callback can be invoked outside DataTable with `{ rows }`. @@ -498,7 +597,7 @@ These APIs use “footer” at different scopes: `DataTable` treats column configuration as two layers: -- **Presentation** — `column.header`, `column.render`, and `column.footer` control what is rendered. +- **Presentation** — `column.header`, `column.render`, and `column.footer` control rendered content; `column.headerProps`, `column.cellProps`, and `column.footerProps` customize the surrounding header, data-cell, and footer elements. - **Behavior/data** — `column.label`, `column.id`, `column.accessor`, `column.sort`, `column.filter`, and collection `control` drive built-in behavior such as sorting, truncation tooltips, persistence, and context-menu actions. This means built-in interactions do **not** infer meaning from rendered DOM content. If a custom-rendered column should still participate in built-in copy/filter/tooltip behavior, give it an `id` or `accessor` that exposes the raw value. @@ -1107,20 +1206,23 @@ A column definition passed to `useDataTable`. `Column` is a discriminated ### Shared fields -| Property | Type | Description | -| ---------- | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `label` | `string` | Column header text. Omit for icon-only columns. | -| `header` | `(ctx: HeaderRenderContext) => ReactNode` | Custom header renderer. Presentation only. When omitted, the built-in header renders `label` and owns the sort button/indicator. When provided, the return value replaces the built-in header entirely; sortable custom headers receive `sortDirection` and `activateSort()` via `ctx` and must render their own click surface for left-click sorting. The header context menu still uses the column metadata (`label`, `sort`, etc.). | -| `footer` | `string \| ((ctx: FooterRenderContext) => ReactNode)` | Optional static string or column footer renderer, including callbacks created by `aggregations`. Renderers receive current `data.rows` (the current page, not selected rows or all records). Enables the automatic sticky `` row; no default content or typed formatting is applied. See [Column footers](#column-footers). | -| `render` | `(row: TRow) => ReactNode` | Renders the cell content. Presentation only. Optional — overrides the built-in `type` display renderer when set, not an editor configured through `edit`. Leave `edit` unset to draw your own editor with `render`. Built-in behaviors that need the raw value (truncate tooltip, cell copy/filter context-menu actions) still resolve it from `accessor` first, then `row[col.id]`, and only fall back to the return value of `render(row)` when it is a primitive (`string`, `number`, `boolean`, or `bigint`). | -| `id` | `string` | Stable identifier for column visibility, persisted layout state, raw-value fallback (`row[col.id]`), and the React key. Falls back to `label` when omitted. Set this explicitly when `label` is absent, not unique, or when a custom-rendered column should still participate in built-in cell behaviors that need a raw value. | -| `width` | `number` | Fixed column width in pixels. Optional. | -| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via the toolbar's column-settings control). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection and expand columns auto-pin left and the row-actions column auto-pin right. | -| `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. | -| `truncate` | `boolean` | Truncate overflowing text with an ellipsis. Wires up an app-shell `` automatically when the resolved raw cell value is a string or number (`accessor` first, then `row[col.id]`). With `inferColumns`, no explicit `accessor` is needed because `id` is pinned to the field name. Requires another column to anchor the row width (`width` on a neighbor, or a fixed-size column like selection / row actions). | -| `accessor` | _(narrowed per `type`)_ | Extracts the raw value. This is the primary source of truth for built-in behaviors that need a value independent of presentation (typed rendering, truncate tooltip, cell copy/filter context-menu actions). The return type is narrowed per `type` branch — returning an array is a compile error on all typed columns except `badge`, and returning a plain object is a compile error on all typed columns. Untyped columns (`type` omitted) retain `unknown`. `null` and `undefined` are always allowed. | -| `sort` | `SortConfig` | Sort configuration. When set, the column participates in the built-in sort behaviors: the default clickable header and the header context-menu sort submenu. Custom headers still need to call `ctx.activateSort()` to opt into left-click sorting. | -| `filter` | `FilterConfig` | Filter configuration. When set, the column appears as an option in `DataTable.Filters` and can also drive the cell **Add filter** context-menu submenu when collection `control` is available. The cell menu narrows this to single-value operators only. | +| Property | Type | Description | +| ------------- | ---------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `label` | `string` | Column header text. Omit for icon-only columns. | +| `header` | `(ctx: HeaderRenderContext) => ReactNode` | Custom header renderer. Presentation only. When omitted, the built-in header renders `label` and owns the sort button/indicator. When provided, the return value replaces the built-in header entirely; sortable custom headers receive `sortDirection` and `activateSort()` via `ctx` and must render their own click surface for left-click sorting. The header context menu still uses the column metadata (`label`, `sort`, etc.). | +| `footer` | `string \| ((ctx: FooterRenderContext) => ReactNode)` | Optional static string or column footer renderer, including callbacks created by `aggregations`. Renderers receive current `data.rows` (the current page, not selected rows or all records). Enables the automatic sticky `` row; no default content or typed formatting is applied. See [Column footers](#column-footers). | +| `render` | `(row: TRow) => ReactNode` | Renders the cell content. Presentation only. Optional — overrides the built-in `type` display renderer when set, not an editor configured through `edit`. Leave `edit` unset to draw your own editor with `render`. Built-in behaviors that need the raw value (truncate tooltip, cell copy/filter context-menu actions) still resolve it from `accessor` first, then `row[col.id]`, and only fall back to the return value of `render(row)` when it is a primitive (`string`, `number`, `boolean`, or `bigint`). | +| `id` | `string` | Stable identifier for column visibility, persisted layout state, raw-value fallback (`row[col.id]`), and the React key. Falls back to `label` when omitted. Set this explicitly when `label` is absent, not unique, or when a custom-rendered column should still participate in built-in cell behaviors that need a raw value. | +| `headerProps` | `(ctx: HeaderRenderContext) => Pick, "className" \| "style" \| "title"> \| undefined` | Sets classes, inline styles, and a native title on this column's `` based on the header context. Return `undefined` to keep the defaults. Does not replace header content or sorting. Column width and sticky positioning take precedence over inline styles. See [Header styling](#header-styling). | +| `cellProps` | `(row: TRow) => Pick, "className" \| "style" \| "title"> \| undefined` | Sets classes, inline styles, and a native title on the data-cell ``. Column width and sticky positioning take precedence over inline styles. See [Per-cell styling](#per-cell-styling). | +| `footerProps` | `(ctx: FooterRenderContext) => Pick, "className" \| "style" \| "title"> \| undefined` | Sets classes, inline styles, and a native title on this column's footer `` based on current rows. Return `undefined` to keep the defaults. Does not create a footer row or replace its content. Column width and sticky positioning take precedence. See [Footer styling](#footer-styling). | +| `width` | `number` | Fixed column width in pixels. Optional. | +| `pin` | `"left" \| "right"` | Freezes the column to that edge so it stays visible during horizontal scroll (the default; the user can override it via the toolbar's column-settings control). Sticky offsets are measured from the rendered layout, so `width` isn't required — but setting `width` on pinned columns is recommended for stable sizing. The selection and expand columns auto-pin left and the row-actions column auto-pin right. | +| `align` | `"left" \| "right"` | Horizontal alignment. Defaults to `"right"` for `type: "number"` and `type: "money"`; `"left"` otherwise. Pass `"left"` to opt a numeric column out. | +| `truncate` | `boolean` | Truncate overflowing text with an ellipsis. Wires up an app-shell `` automatically when the resolved raw cell value is a string or number (`accessor` first, then `row[col.id]`). With `inferColumns`, no explicit `accessor` is needed because `id` is pinned to the field name. Requires another column to anchor the row width (`width` on a neighbor, or a fixed-size column like selection / row actions). | +| `accessor` | _(narrowed per `type`)_ | Extracts the raw value. This is the primary source of truth for built-in behaviors that need a value independent of presentation (typed rendering, truncate tooltip, cell copy/filter context-menu actions). The return type is narrowed per `type` branch — returning an array is a compile error on all typed columns except `badge`, and returning a plain object is a compile error on all typed columns. Untyped columns (`type` omitted) retain `unknown`. `null` and `undefined` are always allowed. | +| `sort` | `SortConfig` | Sort configuration. When set, the column participates in the built-in sort behaviors: the default clickable header and the header context-menu sort submenu. Custom headers still need to call `ctx.activateSort()` to opt into left-click sorting. | +| `filter` | `FilterConfig` | Filter configuration. When set, the column appears as an option in `DataTable.Filters` and can also drive the cell **Add filter** context-menu submenu when collection `control` is available. The cell menu narrows this to single-value operators only. | ### `type`-specific fields diff --git a/packages/core/src/components/data-table/data-table-styling.test.tsx b/packages/core/src/components/data-table/data-table-styling.test.tsx new file mode 100644 index 00000000..e36f9934 --- /dev/null +++ b/packages/core/src/components/data-table/data-table-styling.test.tsx @@ -0,0 +1,440 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { createAppShellWrapper } from "../../../tests/test-utils"; +import { DataTable } from "./data-table"; +import { useDataTable } from "./use-data-table"; +import type { + Column, + FooterRenderContext, + HeaderRenderContext, + UseDataTableOptions, + UseDataTableReturn, +} from "./types"; + +afterEach(cleanup); + +type Row = { id: string; name: string; hours: number }; +const rows: Row[] = [ + { id: "1", name: "Alex", hours: 60 }, + { id: "2", name: "Sam", hours: 20 }, +]; +const wrapper = createAppShellWrapper("en"); + +function Harness({ + capture, + ...options +}: UseDataTableOptions & { capture?: (table: UseDataTableReturn) => void }) { + const table = useDataTable(options); + capture?.(table); + return ( + + + + ); +} + +const cell = (name: string) => screen.getByText(name).closest("td")!; + +describe("DataTable cellProps", () => { + it("limits the public contract to className, style, and title", () => { + type CellProps = NonNullable["cellProps"]>>>; + expectTypeOf().toEqualTypeOf< + Pick, "className" | "style" | "title"> + >(); + }); + + it.each(["built-in", "custom", "truncated", "editable"])( + "styles the actual cell with a %s renderer", + (mode) => { + render( + {row.name} : undefined, + truncate: mode === "truncated", + edit: mode === "editable" ? { onValueCommitted: vi.fn() } : undefined, + cellProps: (row) => { + expectTypeOf(row).toEqualTypeOf(); + return row.hours >= 40 + ? { + className: "astw:tabular-nums", + style: { backgroundColor: "red", color: "white" }, + title: `${row.hours} hours`, + } + : undefined; + }, + }, + ]} + />, + { wrapper }, + ); + + expect(cell("Alex").style.backgroundColor).toBe("red"); + expect(cell("Alex").style.color).toBe("white"); + expect(cell("Alex").className).toContain("astw:py-2"); + expect(cell("Alex").className).toContain("astw:tabular-nums"); + expect(cell("Alex").title).toBe("60 hours"); + expect(cell("Sam").style.backgroundColor).toBe(""); + expect(cell("Sam").title).toBe(""); + expect(cell("Sam").className).not.toContain("astw:tabular-nums"); + const header = screen.getByRole("columnheader"); + expect(header.style.backgroundColor).toBe(""); + expect(header.title).toBe(""); + expect(header.className).not.toContain("astw:tabular-nums"); + }, + ); + + it.each(["left", "right"] as const)( + "preserves %s pinning, width, and selection with a custom background", + (pin) => { + render( + ({ + className: "astw:bg-muted", + style: { + backgroundColor: "red", + position: "relative", + [pin]: 999, + width: 999, + }, + }), + }, + { id: "hours", label: "Hours", type: "number" }, + ]} + />, + { wrapper }, + ); + + const styled = cell("Alex"); + expect(styled.style.position).toBe("sticky"); + expect(styled.style[pin]).toBe(pin === "left" ? "52px" : "0px"); + expect(styled.style.width).toBe("140px"); + expect(styled.className).toContain(`data-pin-shadow-${pin}`); + expect(styled.className.split(" ")).toContain("astw:bg-muted"); + expect(styled.className.split(" ")).not.toContain("astw:bg-card"); + expect(styled.className).toContain("astw:group-aria-selected:bg-muted"); + expect(styled.style.backgroundColor).toBe("red"); + fireEvent.click(screen.getAllByRole("checkbox", { name: "Select row" })[0]); + expect(styled.closest("tr")?.getAttribute("aria-selected")).toBe("true"); + expect(styled.style.backgroundColor).toBe("red"); + expect(cell("60").style.backgroundColor).toBe(""); + }, + ); + + it("recomputes and removes styles when row data changes", () => { + const columns: Column[] = [ + { + id: "name", + type: "text", + cellProps: (row) => + row.hours >= 40 + ? { + className: "astw:tabular-nums", + style: { backgroundColor: "red" }, + title: "High overtime", + } + : undefined, + }, + ]; + const { rerender } = render(, { wrapper }); + expect(cell("Alex").style.backgroundColor).toBe("red"); + rerender(); + expect(cell("Alex").style.backgroundColor).toBe(""); + expect(cell("Alex").title).toBe(""); + expect(cell("Alex").className).not.toContain("astw:tabular-nums"); + }); + + it.each([{ loading: true }, { data: { rows: [] } }, { error: new Error("Failed") }])( + "does not evaluate row styling for status or loading cells (%j)", + (state) => { + const cellProps = vi.fn(() => ({ style: { backgroundColor: "red" } })); + render( + , + { wrapper }, + ); + expect(cellProps).not.toHaveBeenCalled(); + }, + ); +}); + +describe("DataTable headerProps", () => { + it("accepts only a callback returning className, style, and title or undefined", () => { + expectTypeOf["headerProps"]>>().toEqualTypeOf< + ( + ctx: HeaderRenderContext, + ) => Pick, "className" | "style" | "title"> | undefined + >(); + }); + + it.each([false, true])("styles the actual header with custom content=%s", (custom) => { + render( + Person : undefined, + headerProps: (ctx) => { + expectTypeOf(ctx).toEqualTypeOf(); + expect(ctx).toEqual({ label: "Name", sortable: false }); + return { + className: "astw:tabular-nums", + style: { backgroundColor: "red", color: "white" }, + title: "Employee name", + }; + }, + }, + { id: "hours", label: "Hours", type: "number" }, + ]} + />, + { wrapper }, + ); + + const header = screen.getByText(custom ? "Person" : "Name").closest("th")!; + expect(header.style.backgroundColor).toBe("red"); + expect(header.style.color).toBe("white"); + expect(header.title).toBe("Employee name"); + expect(header.className).toContain("astw:px-2"); + expect(header.className).toContain("astw:tabular-nums"); + expect(cell("Alex").style.backgroundColor).toBe(""); + expect(cell("Alex").title).toBe(""); + for (const other of screen.getAllByRole("columnheader").filter((th) => th !== header)) { + expect(other.style.backgroundColor).toBe(""); + expect(other.title).toBe(""); + expect(other.className).not.toContain("astw:tabular-nums"); + } + }); + + it.each(["left", "right"] as const)("preserves %s header pinning and width", (pin) => { + render( + ({ + className: "astw:bg-muted", + style: { backgroundColor: "red", position: "relative", [pin]: 999, width: 999 }, + }), + }, + ]} + />, + { wrapper }, + ); + + const header = screen.getByText("Name").closest("th")!; + expect(header.style.position).toBe("sticky"); + expect(header.style[pin]).toBe(pin === "left" ? "52px" : "0px"); + expect(header.style.width).toBe("140px"); + expect(header.style.backgroundColor).toBe("red"); + expect(header.className.split(" ")).toContain("astw:bg-muted"); + expect(header.className.split(" ")).not.toContain("astw:bg-card"); + expect(header.className).toContain(`data-pin-shadow-${pin}`); + }); + + it("removes header customizations when the column definition changes", () => { + const column: Column = { id: "name", label: "Name", type: "text" }; + const { rerender } = render( + ({ + className: "astw:tabular-nums", + style: { backgroundColor: "red" }, + title: "Employee name", + }), + }, + ]} + />, + { wrapper }, + ); + expect(screen.getByRole("columnheader").title).toBe("Employee name"); + rerender(); + const header = screen.getByRole("columnheader"); + expect(header.style.backgroundColor).toBe(""); + expect(header.title).toBe(""); + expect(header.className).not.toContain("astw:tabular-nums"); + }); +}); + +describe("DataTable footerProps", () => { + it("accepts only a callback returning className, style, and title or undefined", () => { + expectTypeOf["footerProps"]>>().toEqualTypeOf< + ( + ctx: FooterRenderContext, + ) => Pick, "className" | "style" | "title"> | undefined + >(); + }); + + it.each([false, true])( + "styles footer cells with custom content=%s without affecting other cells", + (custom) => { + const { container } = render( + "Details" }} + rowActions={[{ id: "inspect", label: "Inspect", onClick: vi.fn() }]} + columns={[ + { + id: "name", + label: "Name", + type: "text", + footer: custom ? () => Total : "Total", + footerProps: (ctx) => { + expectTypeOf(ctx).toEqualTypeOf>(); + expectTypeOf(ctx.rows).toEqualTypeOf(); + expect(ctx.rows).toBe(rows); + return { + className: "astw:tabular-nums", + style: { backgroundColor: "red", color: "white" }, + title: "Page total", + }; + }, + }, + { id: "hours", type: "number", footerProps: () => ({ title: "Blank footer" }) }, + ]} + />, + { wrapper }, + ); + const footer = cell("Total"); + expect(footer.style.backgroundColor).toBe("red"); + expect(footer.style.color).toBe("white"); + expect(footer.className).toContain("astw:py-2"); + expect(footer.className).toContain("astw:tabular-nums"); + expect(footer.title).toBe("Page total"); + const blank = container.querySelector('tfoot [data-col-key="hours"]')!; + expect(blank.title).toBe("Blank footer"); + expect(blank.textContent).toBe(""); + for (const other of Array.from(container.querySelectorAll("th, td"))) { + if (other === footer || other === blank) continue; + expect(other.style.backgroundColor).toBe(""); + expect(other.title).toBe(""); + expect(other.className).not.toContain("astw:tabular-nums"); + } + }, + ); + + it.each(["left", "right"] as const)("preserves %s footer pinning and width", (pin) => { + render( + ({ + className: "astw:bg-muted", + style: { backgroundColor: "red", position: "relative", [pin]: 999, width: 999 }, + }), + }, + ]} + />, + { wrapper }, + ); + const footer = cell("Total"); + expect(footer.style.position).toBe("sticky"); + expect(footer.style[pin]).toBe(pin === "left" ? "52px" : "0px"); + expect(footer.style.width).toBe("140px"); + expect(footer.style.backgroundColor).toBe("red"); + expect(footer.className.split(" ")).toContain("astw:bg-muted"); + expect(footer.className.split(" ")).not.toContain("astw:bg-card"); + expect(footer.className).toContain(`data-pin-shadow-${pin}`); + }); + + it("recomputes footer styling from current rows, restores defaults, and evaluates empty rows", () => { + const footerProps = vi.fn(({ rows: currentRows }: FooterRenderContext) => + currentRows.some((row) => row.hours >= 40) + ? { className: "astw:tabular-nums", style: { backgroundColor: "red" }, title: "High total" } + : undefined, + ); + const columns: Column[] = [{ id: "name", type: "text", footer: "Total", footerProps }]; + const { rerender } = render(, { wrapper }); + expect(cell("Total").style.backgroundColor).toBe("red"); + rerender(); + expect(cell("Total").style.backgroundColor).toBe(""); + expect(cell("Total").title).toBe(""); + expect(cell("Total").className).not.toContain("astw:tabular-nums"); + footerProps.mockClear(); + rerender(); + expect(footerProps).toHaveBeenLastCalledWith({ rows: [] }); + expect(cell("Total").style.backgroundColor).toBe(""); + }); + + it.each([{ loading: true }, { error: new Error("Failed") }])( + "does not evaluate footer styling during loading or errors (%j)", + (state) => { + const footerProps = vi.fn(() => ({ title: "Page total" })); + render( + , + { wrapper }, + ); + expect(footerProps).not.toHaveBeenCalled(); + }, + ); + + it("does not evaluate hidden columns while another footer remains visible", () => { + const footerProps = vi.fn(() => ({ title: "Page total" })); + let table!: UseDataTableReturn; + render( + { + table = value; + }} + columns={[ + { id: "name", footer: "Total", footerProps }, + { id: "hours", footer: "Hours" }, + ]} + />, + { wrapper }, + ); + footerProps.mockClear(); + act(() => table.toggleColumn("name")); + expect(footerProps).not.toHaveBeenCalled(); + expect(screen.queryByText("Total")).toBeNull(); + expect(cell("Hours")).toBeDefined(); + }); + + it("does not create a footer from footerProps alone", () => { + const footerProps = vi.fn(() => ({ title: "Page total" })); + const { container } = render( + , + { wrapper }, + ); + expect(container.querySelector("tfoot")).toBeNull(); + expect(footerProps).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/components/data-table/data-table.test.tsx b/packages/core/src/components/data-table/data-table.test.tsx index 2b1055fd..865ef7ba 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -874,7 +874,7 @@ describe("DataTable", () => { expect(screen.queryByText("Name")).toBeNull(); }); - it("keeps built-in sort behavior and hit area when header is omitted", () => { + it("keeps built-in sort behavior, hit area, and context menu with headerProps", () => { const control = makeControl(); function Harness() { @@ -883,6 +883,11 @@ describe("DataTable", () => { { label: "Name", sort: { field: "name", type: "string" }, + headerProps: () => ({ + className: "astw:tabular-nums", + style: { backgroundColor: "red" }, + title: "Employee name", + }), render: (row) => row.name, }, ], @@ -897,8 +902,9 @@ describe("DataTable", () => { ); } - render(, { wrapper }); + const { container } = render(, { wrapper }); + expect(headByText(container, "Name")?.title).toBe("Employee name"); const button = screen.getByRole("button", { name: "Name" }); expect(button.className).toContain("astw:h-10"); expect(button.className).toContain("astw:-mx-2"); @@ -909,6 +915,59 @@ describe("DataTable", () => { expect(control.clearSort).toHaveBeenCalledTimes(1); expect(control.setSort).toHaveBeenCalledTimes(1); expect(control.setSort).toHaveBeenCalledWith("name", "Asc"); + + openHeaderContextMenu(container, "Name"); + expect(screen.getByRole("menuitem", { name: "Copy label" })).toBeDefined(); + }); + + it("recomputes headerProps from sort context and restores defaults for undefined", () => { + const columns: Column[] = [ + { + label: "Name", + sort: { field: "name", type: "string" }, + render: (row) => row.name, + headerProps: (ctx) => { + if (!ctx.sortable) return undefined; + expectTypeOf(ctx.sortDirection).toEqualTypeOf<"Asc" | "Desc" | undefined>(); + expectTypeOf(ctx.activateSort).toEqualTypeOf<() => void>(); + return ctx.sortDirection + ? { + className: "astw:tabular-nums", + style: { backgroundColor: ctx.sortDirection === "Asc" ? "red" : "blue" }, + title: `${ctx.label} ${ctx.sortDirection}`, + } + : undefined; + }, + }, + ]; + function Harness({ control }: { control?: CollectionControl }) { + const table = useDataTable({ columns, data: testData, control }); + return ( + + + + ); + } + + const { rerender } = render( + , + { wrapper }, + ); + expect(screen.getByRole("columnheader").title).toBe("Name Asc"); + expect(screen.getByRole("columnheader").style.backgroundColor).toBe("red"); + rerender( + , + ); + expect(screen.getByRole("columnheader").title).toBe("Name Desc"); + expect(screen.getByRole("columnheader").style.backgroundColor).toBe("blue"); + + for (const control of [makeControl(), undefined]) { + rerender(); + const header = screen.getByRole("columnheader"); + expect(header.style.backgroundColor).toBe(""); + expect(header.title).toBe(""); + expect(header.className).not.toContain("astw:tabular-nums"); + } }); it("passes non-sortable context when sort config exists but sorting is inactive", () => { diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 11d82aaa..9ea32852 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -1037,6 +1037,7 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } } : { label, sortable: false }; + const customHeaderProps = col.headerProps?.(headerContext); const align = resolveAlign(col); const content = col.header ? col.header(headerContext) @@ -1048,13 +1049,22 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } const { style, className } = pinCellProps( placements.get(col), { - style: col.width ? { width: col.width } : undefined, + style: { + ...customHeaderProps?.style, + ...(col.width ? { width: col.width } : {}), + }, className: cn(align === "right" && "astw:text-right"), }, "header", ); return ( - + { const key = keys.get(col) as string; + const footerContext = { rows }; + const customFooterProps = loading ? undefined : col.footerProps?.(footerContext); let footerContent: ReactNode = null; if (!loading) { - footerContent = typeof col.footer === "function" ? col.footer({ rows }) : col.footer; + footerContent = + typeof col.footer === "function" ? col.footer(footerContext) : col.footer; } + const { style, className } = pinCellProps( + placements.get(col), + { + style: { + ...customFooterProps?.style, + ...(col.width ? { width: col.width } : {}), + }, + className: cn(resolveAlign(col) === "right" && "astw:text-right"), + }, + "footer", + ); return ( {col.footer !== undefined && (loading ? ( @@ -1500,10 +1519,14 @@ function DataTableRows>({ let content: ReactNode; if (!editable) content = col.render ? col.render(row) : cellRenderers.get(col)?.(row); + const customCellProps = col.cellProps?.(row); const { style: cellStyle, className: cellClassName } = pinCellProps( placements.get(col), { - style: col.width ? { width: col.width } : undefined, + style: { + ...customCellProps?.style, + ...(col.width ? { width: col.width } : {}), + }, className: cn( resolveAlign(col) === "right" && "astw:text-right", // Keep the width constraint on the cell, but move the @@ -1558,10 +1581,12 @@ function DataTableRows>({ }); const cellProps = { "data-slot": "data-table-cell" as const, + title: customCellProps?.title, style: { ...cellStyle, WebkitTouchCallout: "none" } satisfies CSSProperties, className: cn( cellClassName, blockReadOnlyCells && !editable && "astw:cursor-not-allowed", + customCellProps?.className, ), ...cellContextMenuHandlers, }; diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 6c221591..fc95aa5b 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -1,4 +1,4 @@ -import type { ReactNode } from "react"; +import type { ComponentProps, ReactNode } from "react"; import type { BadgeOptions } from "@/components/badge-list"; import type { CollectionControl, @@ -228,11 +228,11 @@ interface SortableHeaderRenderContext { } /** - * Context passed to custom `header` renderers. + * Context passed to custom `header` renderers and `headerProps` callbacks. */ export type HeaderRenderContext = NonSortableHeaderRenderContext | SortableHeaderRenderContext; -/** Context passed to custom column `footer` renderers. */ +/** Context passed to custom column `footer` renderers and `footerProps` callbacks. */ export interface FooterRenderContext> { /** Current `data.rows` (the current page, not all records or only selected rows). */ rows: readonly TRow[]; @@ -328,6 +328,19 @@ export interface ColumnBase> { * across spread-then-override patterns like `column({ ...inferred, render })`. */ render?: (row: TRow) => ReactNode; + /** + * Sets `className`, `style`, and `title` on the data cell's ``, including + * its padding, based on the row. Return `undefined` to keep the defaults. + * Classes are merged with the built-in classes; CSS specificity still applies. + * An inline `style.backgroundColor` remains visible during row hover and + * selection, including on pinned cells. Use an opaque background for pinned + * cells to prevent content bleeding through. Column `width` and sticky + * positioning take precedence over `style`. Content stays owned by `render`; + * events, refs, and cell spanning are not configurable through this hook. + */ + cellProps?: ( + row: TRow, + ) => Pick, "className" | "style" | "title"> | undefined; /** * Custom header renderer. * @@ -340,6 +353,18 @@ export interface ColumnBase> { * still driven by `label`, `sort`, and other column metadata. */ header?: (ctx: HeaderRenderContext) => ReactNode; + /** + * Sets `className`, `style`, and `title` on this column's header ``. + * Receives the same context as `header`; return `undefined` to keep the defaults. + * Classes are merged with the built-in classes; column `width` and sticky + * positioning take precedence over `style`. Use an opaque background for + * pinned headers. Content stays owned by `header` or the default renderer; + * sorting and context-menu behavior are unchanged. Built-in selection, + * expansion, and action headers are not affected. + */ + headerProps?: ( + ctx: HeaderRenderContext, + ) => Pick, "className" | "style" | "title"> | undefined; /** * Content for this column's cell in a sticky table footer: a static string * or a renderer receiving the current `data.rows` (the current page, not all @@ -353,6 +378,19 @@ export interface ColumnBase> { * Separate from `DataTable.Footer`, the pagination / bulk-action container. */ footer?: string | ((ctx: FooterRenderContext) => ReactNode); + /** + * Sets `className`, `style`, and `title` on this column's footer ``. + * Receives the same context as `footer`; return `undefined` to keep the defaults. + * Classes are merged with the built-in classes; column `width` and sticky + * positioning take precedence over `style`. Use an opaque background for + * pinned footer cells. Content stays owned by `footer`; events, refs, and + * cell spanning are not configurable through this hook. + * Does not create a footer row by itself. Not called while loading, on errors, + * or for hidden columns. Built-in utility footer cells are not affected. + */ + footerProps?: ( + ctx: FooterRenderContext, + ) => Pick, "className" | "style" | "title"> | undefined; /** * Stable identifier used for column visibility toggling, persisted layout * state, raw-value fallback (`row[col.id]`), and as the React key. Falls