From 7cb1bb28b31eac156b9f80ffb50d2725a4f92a1f Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Wed, 21 Oct 2026 17:32:32 +0900 Subject: [PATCH 1/3] feat(data-table): add cell and header props customization --- .changeset/bright-cells-shine.md | 24 ++ docs-manifest.json | 8 +- .../components/data-table.docs.examples.tsx | 61 ++++ .../components/data-table.docs.outline.md | 46 ++- docs/components/data-table.md | 109 ++++++- .../data-table/data-table-styling.test.tsx | 266 ++++++++++++++++++ .../components/data-table/data-table.test.tsx | 13 +- .../src/components/data-table/data-table.tsx | 21 +- .../core/src/components/data-table/types.ts | 25 +- 9 files changed, 535 insertions(+), 38 deletions(-) create mode 100644 .changeset/bright-cells-shine.md create mode 100644 packages/core/src/components/data-table/data-table-styling.test.tsx diff --git a/.changeset/bright-cells-shine.md b/.changeset/bright-cells-shine.md new file mode 100644 index 000000000..432644321 --- /dev/null +++ b/.changeset/bright-cells-shine.md @@ -0,0 +1,24 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add row-aware `cellProps` and static `headerProps` to DataTable columns to set `className`, `style`, and `title` on the entire data/header cell, including padding. +Inline body-cell backgrounds remain visible on hover and selection, including in pinned columns. Header 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", + }, + 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 d77b51ac4..47e5cde00 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -454,10 +454,10 @@ ], "hashes": { "typeSurface": "3ca8fce7d2efca2d", - "outline": "0b7a57e7d2fc04a5", + "outline": "7281de586c679363", "snapshot": null, - "outputMd": "1073852822f9e535", - "examples": "b3468077b9455521" + "outputMd": "66a99c767df3cf2e", + "examples": "4228b323423f4292" } }, "date-picker": { @@ -1751,7 +1751,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": "1073852822f9e535", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "66a99c767df3cf2e", "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 2dc3a51fb..39ec8be74 100644 --- a/docs-src/components/data-table.docs.examples.tsx +++ b/docs-src/components/data-table.docs.examples.tsx @@ -1,6 +1,67 @@ import { useRef, useState } from "react"; import { Button, DataTable, createColumnHelper, 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 ImmediateSaving() { type Line = { id: string; item: string; ordered: number; received: number | null }; const initialRows: Line[] = [ diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 22bd9abce..3240a6cf7 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -267,6 +267,22 @@ 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, 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 + +Set the column's `headerProps` to a static object containing `className`, `style`, and/or `title` to customize the actual ``, including its padding. Unlike `cellProps(row)`, it is not a callback. 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 `headerProps.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. + + + ## Context menus `DataTable.Table` adds right-click context menus to both headers and cells: @@ -278,7 +294,7 @@ const table = useDataTable({ `DataTable` treats column configuration as two layers: -- **Presentation** — `column.header` and `column.render` control what is rendered. +- **Presentation** — `column.header` and `column.render` control content; `column.headerProps` and `column.cellProps` customize the surrounding cell 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. @@ -663,19 +679,21 @@ 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.). | -| `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.). | +| `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` | `Pick, "className" \| "style" \| "title">` | Static classes, inline styles, and native title for this column's ``. 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). | +| `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 e7730ec41..9b63d2b7a 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -261,6 +261,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, 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 + +Set the column's `headerProps` to a static object containing `className`, `style`, and/or `title` to customize the actual ``, including its padding. Unlike `cellProps(row)`, it is not a callback. 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 `headerProps.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.

+ + + +
+ ); +} +``` + ## Context menus `DataTable.Table` adds right-click context menus to both headers and cells: @@ -272,7 +351,7 @@ const table = useDataTable({ `DataTable` treats column configuration as two layers: -- **Presentation** — `column.header` and `column.render` control what is rendered. +- **Presentation** — `column.header` and `column.render` control content; `column.headerProps` and `column.cellProps` customize the surrounding cell 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. @@ -830,19 +909,21 @@ 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.). | -| `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.). | +| `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` | `Pick, "className" \| "style" \| "title">` | Static classes, inline styles, and native title for this column's ``. 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). | +| `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 000000000..3af5022c4 --- /dev/null +++ b/packages/core/src/components/data-table/data-table-styling.test.tsx @@ -0,0 +1,266 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; +import { 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, UseDataTableOptions } 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(options: UseDataTableOptions) { + const table = useDataTable(options); + 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 static object with className, style, and title", () => { + expectTypeOf["headerProps"]>>().toEqualTypeOf< + Pick, "className" | "style" | "title"> + >(); + }); + + it.each([false, true])("styles the actual header with custom content=%s", (custom) => { + render( + Person : undefined, + headerProps: { + 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( + , + { 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( + , + { 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"); + }); +}); 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 578a27b16..3dd0627d5 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,9 @@ 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("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 befb229fe..ebc332bd7 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -1048,13 +1048,22 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } const { style, className } = pinCellProps( placements.get(col), { - style: col.width ? { width: col.width } : undefined, + style: { + ...col.headerProps?.style, + ...(col.width ? { width: col.width } : {}), + }, className: cn(align === "right" && "astw:text-right"), }, "header", ); return ( - + >({ 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 @@ -1441,10 +1454,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 01b230ada..51cf0843b 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, @@ -302,6 +302,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. * @@ -314,6 +327,16 @@ 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 ``. + * This is a static object, unlike the row-aware `cellProps` callback. + * 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?: Pick, "className" | "style" | "title">; /** * Stable identifier used for column visibility toggling, persisted layout * state, raw-value fallback (`row[col.id]`), and as the React key. Falls From 1b6896135ccd0546a4e59c623c6afe5e6dc5e845 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 22 Oct 2026 14:11:48 +0900 Subject: [PATCH 2/3] refactor(data-table): align headerProps with cellProps callbacks --- .changeset/bright-cells-shine.md | 6 +-- docs-manifest.json | 8 +-- .../components/data-table.docs.examples.tsx | 4 +- .../components/data-table.docs.outline.md | 36 ++++++------- docs/components/data-table.md | 38 ++++++------- .../data-table/data-table-styling.test.tsx | 28 ++++++---- .../components/data-table/data-table.test.tsx | 54 ++++++++++++++++++- .../src/components/data-table/data-table.tsx | 7 +-- .../core/src/components/data-table/types.ts | 8 +-- 9 files changed, 124 insertions(+), 65 deletions(-) diff --git a/.changeset/bright-cells-shine.md b/.changeset/bright-cells-shine.md index 432644321..811e147fa 100644 --- a/.changeset/bright-cells-shine.md +++ b/.changeset/bright-cells-shine.md @@ -2,17 +2,17 @@ "@tailor-platform/app-shell": minor --- -Add row-aware `cellProps` and static `headerProps` to DataTable columns to set `className`, `style`, and `title` on the entire data/header cell, including padding. +Add `cellProps(row)` and `headerProps(ctx)` callbacks to DataTable columns to set `className`, `style`, and `title` on the entire data/header cell, including padding. Both can return `undefined` to keep the defaults; `headerProps` receives the same label and sort context as `header`. Inline body-cell backgrounds remain visible on hover and selection, including in pinned columns. Header content, sorting, and context menus keep their existing behavior; existing tables are unchanged. ```tsx column({ id: "hours", type: "number", - headerProps: { + headerProps: () => ({ style: { backgroundColor: "var(--muted)" }, title: "Monthly overtime hours", - }, + }), cellProps: (row) => row.hours >= 60 ? { diff --git a/docs-manifest.json b/docs-manifest.json index 47e5cde00..33d940f21 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -454,10 +454,10 @@ ], "hashes": { "typeSurface": "3ca8fce7d2efca2d", - "outline": "7281de586c679363", + "outline": "60854c65bcf0997a", "snapshot": null, - "outputMd": "66a99c767df3cf2e", - "examples": "4228b323423f4292" + "outputMd": "fc46dfe9da83ba8f", + "examples": "410dabe10e067bcf" } }, "date-picker": { @@ -1751,7 +1751,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": "66a99c767df3cf2e", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "fc46dfe9da83ba8f", "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 39ec8be74..74c707dbd 100644 --- a/docs-src/components/data-table.docs.examples.tsx +++ b/docs-src/components/data-table.docs.examples.tsx @@ -37,11 +37,11 @@ export function PerCellStyling() { type: "number", width: 180, pin: "right", - headerProps: { + headerProps: () => ({ className: "astw:whitespace-nowrap", style: { backgroundColor: "var(--muted)" }, title: "February overtime summary", - }, + }), cellProps: (row) => ({ className: "astw:tabular-nums", style: overtimeStyle(row.february), diff --git a/docs-src/components/data-table.docs.outline.md b/docs-src/components/data-table.docs.outline.md index 3240a6cf7..4e30416bf 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -277,11 +277,11 @@ Column `width` and the pinned cell's sticky position/edge offset take precedence ### Header styling -Set the column's `headerProps` to a static object containing `className`, `style`, and/or `title` to customize the actual ``, including its padding. Unlike `cellProps(row)`, it is not a callback. 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. +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 `headerProps.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. +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. - + ## Context menus @@ -679,21 +679,21 @@ 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.). | -| `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` | `Pick, "className" \| "style" \| "title">` | Static classes, inline styles, and native title for this column's ``. 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). | -| `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.). | +| `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). | +| `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 9b63d2b7a..5727625b6 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -271,9 +271,9 @@ Column `width` and the pinned cell's sticky position/edge offset take precedence ### Header styling -Set the column's `headerProps` to a static object containing `className`, `style`, and/or `title` to customize the actual ``, including its padding. Unlike `cellProps(row)`, it is not a callback. 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. +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 `headerProps.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. +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. @@ -314,11 +314,11 @@ function PerCellStyling() { type: "number", width: 180, pin: "right", - headerProps: { + headerProps: () => ({ className: "astw:whitespace-nowrap", style: { backgroundColor: "var(--muted)" }, title: "February overtime summary", - }, + }), cellProps: (row) => ({ className: "astw:tabular-nums", style: overtimeStyle(row.february), @@ -909,21 +909,21 @@ 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.). | -| `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` | `Pick, "className" \| "style" \| "title">` | Static classes, inline styles, and native title for this column's ``. 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). | -| `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.). | +| `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). | +| `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 index 3af5022c4..3388cd90c 100644 --- a/packages/core/src/components/data-table/data-table-styling.test.tsx +++ b/packages/core/src/components/data-table/data-table-styling.test.tsx @@ -4,7 +4,7 @@ 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, UseDataTableOptions } from "./types"; +import type { Column, HeaderRenderContext, UseDataTableOptions } from "./types"; afterEach(cleanup); @@ -162,9 +162,11 @@ describe("DataTable cellProps", () => { }); describe("DataTable headerProps", () => { - it("accepts only a static object with className, style, and title", () => { + it("accepts only a callback returning className, style, and title or undefined", () => { expectTypeOf["headerProps"]>>().toEqualTypeOf< - Pick, "className" | "style" | "title"> + ( + ctx: HeaderRenderContext, + ) => Pick, "className" | "style" | "title"> | undefined >(); }); @@ -179,10 +181,14 @@ describe("DataTable headerProps", () => { label: "Name", type: "text", header: custom ? () => Person : undefined, - headerProps: { - className: "astw:tabular-nums", - style: { backgroundColor: "red", color: "white" }, - title: "Employee name", + 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" }, @@ -218,10 +224,10 @@ describe("DataTable headerProps", () => { type: "text", width: 140, pin, - headerProps: { + headerProps: () => ({ className: "astw:bg-muted", style: { backgroundColor: "red", position: "relative", [pin]: 999, width: 999 }, - }, + }), }, ]} />, @@ -246,11 +252,11 @@ describe("DataTable headerProps", () => { columns={[ { ...column, - headerProps: { + headerProps: () => ({ className: "astw:tabular-nums", style: { backgroundColor: "red" }, title: "Employee name", - }, + }), }, ]} />, 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 3dd0627d5..cf7d45e23 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -883,11 +883,11 @@ describe("DataTable", () => { { label: "Name", sort: { field: "name", type: "string" }, - headerProps: { + headerProps: () => ({ className: "astw:tabular-nums", style: { backgroundColor: "red" }, title: "Employee name", - }, + }), render: (row) => row.name, }, ], @@ -920,6 +920,56 @@ describe("DataTable", () => { 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", () => { let seenSortable: boolean | undefined; const columns: Column[] = [ diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index ebc332bd7..0fccd5302 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) @@ -1049,7 +1050,7 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } placements.get(col), { style: { - ...col.headerProps?.style, + ...customHeaderProps?.style, ...(col.width ? { width: col.width } : {}), }, className: cn(align === "right" && "astw:text-right"), @@ -1060,9 +1061,9 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } > { header?: (ctx: HeaderRenderContext) => ReactNode; /** * Sets `className`, `style`, and `title` on this column's header ``. - * This is a static object, unlike the row-aware `cellProps` callback. + * 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?: Pick, "className" | "style" | "title">; + headerProps?: ( + ctx: HeaderRenderContext, + ) => 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 From 206f182487a7373303a9188eb56f90ba011a65f8 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Mon, 26 Oct 2026 15:51:55 +0900 Subject: [PATCH 3/3] feat(data-table): add footer props customization --- .changeset/bright-cells-shine.md | 9 +- docs-manifest.json | 8 +- .../components/data-table.docs.examples.tsx | 11 ++ .../components/data-table.docs.outline.md | 48 +++-- docs/components/data-table.md | 57 ++++-- .../data-table/data-table-styling.test.tsx | 174 +++++++++++++++++- .../src/components/data-table/data-table.tsx | 27 ++- .../core/src/components/data-table/types.ts | 15 +- 8 files changed, 293 insertions(+), 56 deletions(-) diff --git a/.changeset/bright-cells-shine.md b/.changeset/bright-cells-shine.md index 811e147fa..4c4c7ea85 100644 --- a/.changeset/bright-cells-shine.md +++ b/.changeset/bright-cells-shine.md @@ -2,8 +2,8 @@ "@tailor-platform/app-shell": minor --- -Add `cellProps(row)` and `headerProps(ctx)` callbacks to DataTable columns to set `className`, `style`, and `title` on the entire data/header cell, including padding. Both can return `undefined` to keep the defaults; `headerProps` receives the same label and sort context as `header`. -Inline body-cell backgrounds remain visible on hover and selection, including in pinned columns. Header content, sorting, and context menus keep their existing behavior; existing tables are unchanged. +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({ @@ -13,6 +13,11 @@ column({ 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 ? { diff --git a/docs-manifest.json b/docs-manifest.json index b9be57bba..51fecd19d 100644 --- a/docs-manifest.json +++ b/docs-manifest.json @@ -455,10 +455,10 @@ ], "hashes": { "typeSurface": "05cbf8a4bdb7a863", - "outline": "c80bb6d00b71d0a6", + "outline": "327df82cc45a480b", "snapshot": "a97e067c66e46ef6", - "outputMd": "cf70b5d510559874", - "examples": "652e1ea72fb172e8" + "outputMd": "4b473886766e5b48", + "examples": "104f7b3a620e072c" } }, "date-picker": { @@ -1752,7 +1752,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": "cf70b5d510559874", + "packages/core/skills/app-shell-patterns/references/components/data-table.md": "4b473886766e5b48", "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 b148e5a9f..92ca39b93 100644 --- a/docs-src/components/data-table.docs.examples.tsx +++ b/docs-src/components/data-table.docs.examples.tsx @@ -89,6 +89,17 @@ export function ColumnFooters() { ), }, footer: ({ rows }) => rows.reduce((sum, row) => sum + (row.quantity ?? 0), 0), + 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 e9a10476e..79b7bc29d 100644 --- a/docs-src/components/data-table.docs.outline.md +++ b/docs-src/components/data-table.docs.outline.md @@ -42,6 +42,7 @@ import { type DateCellEditOptions, type CellEditState, type DateTimeDraft, + type FooterRenderContext, } from "@tailor-platform/app-shell"; ``` @@ -269,7 +270,7 @@ 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, 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`. +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. @@ -287,7 +288,7 @@ Classes are merged after built-in classes, and column `width` and sticky positio Set `column.footer` to a static string such as `footer: "Total"`, or a renderer for a total or other content beneath that column without adding synthetic rows to `data.rows`. `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. @@ -297,6 +298,14 @@ Set `column.footer` to a static string such as `footer: "Total"`, or a renderer - **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. + ### Column footer vs. table footer These APIs use “footer” at different scopes: @@ -317,7 +326,7 @@ These APIs use “footer” at different scopes: `DataTable` treats column configuration as two layers: -- **Presentation** — `column.header`, `column.render`, and `column.footer` control rendered content; `column.headerProps` and `column.cellProps` customize the surrounding header and data-cell elements. +- **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. @@ -702,22 +711,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. 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). | -| `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. 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 546a254a2..5d958d203 100644 --- a/docs/components/data-table.md +++ b/docs/components/data-table.md @@ -36,6 +36,7 @@ import { type DateCellEditOptions, type CellEditState, type DateTimeDraft, + type FooterRenderContext, } from "@tailor-platform/app-shell"; ``` @@ -263,7 +264,7 @@ 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, 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`. +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. @@ -374,6 +375,17 @@ function ColumnFooters() { ), }, footer: ({ rows }) => rows.reduce((sum, row) => sum + (row.quantity ?? 0), 0), + 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, }), ], }); @@ -394,6 +406,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. + ### Column footer vs. table footer These APIs use “footer” at different scopes: @@ -414,7 +434,7 @@ These APIs use “footer” at different scopes: `DataTable` treats column configuration as two layers: -- **Presentation** — `column.header`, `column.render`, and `column.footer` control rendered content; `column.headerProps` and `column.cellProps` customize the surrounding header and data-cell elements. +- **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. @@ -972,22 +992,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. 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). | -| `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. 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 index 3388cd90c..e36f99349 100644 --- a/packages/core/src/components/data-table/data-table-styling.test.tsx +++ b/packages/core/src/components/data-table/data-table-styling.test.tsx @@ -1,10 +1,16 @@ import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; -import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +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, HeaderRenderContext, UseDataTableOptions } from "./types"; +import type { + Column, + FooterRenderContext, + HeaderRenderContext, + UseDataTableOptions, + UseDataTableReturn, +} from "./types"; afterEach(cleanup); @@ -15,8 +21,12 @@ const rows: Row[] = [ ]; const wrapper = createAppShellWrapper("en"); -function Harness(options: UseDataTableOptions) { +function Harness({ + capture, + ...options +}: UseDataTableOptions & { capture?: (table: UseDataTableReturn) => void }) { const table = useDataTable(options); + capture?.(table); return ( @@ -270,3 +280,161 @@ describe("DataTable headerProps", () => { 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.tsx b/packages/core/src/components/data-table/data-table.tsx index 655fdec27..9ea328529 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -1288,23 +1288,32 @@ function DataTableColumnFooter() { )} {ordered.map((col) => { 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 ? ( diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 3681cd782..945fa90f9 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -212,7 +212,7 @@ interface SortableHeaderRenderContext { */ 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[]; @@ -358,6 +358,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