From 60ca0a5a038701c41b6cec53d36d56985cc83243 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 23 Jul 2026 15:19:06 +0900 Subject: [PATCH 1/3] feat(data-table): add custom header renderer --- .changeset/quick-wombats-judge.md | 22 +++ .../components/data-table/data-table.test.tsx | 140 +++++++++++++++++- .../src/components/data-table/data-table.tsx | 32 ++-- .../core/src/components/data-table/index.ts | 1 + .../core/src/components/data-table/types.ts | 36 ++++- packages/core/src/index.ts | 1 + 6 files changed, 219 insertions(+), 13 deletions(-) create mode 100644 .changeset/quick-wombats-judge.md diff --git a/.changeset/quick-wombats-judge.md b/.changeset/quick-wombats-judge.md new file mode 100644 index 00000000..41d3e510 --- /dev/null +++ b/.changeset/quick-wombats-judge.md @@ -0,0 +1,22 @@ +--- +"@tailor-platform/app-shell": minor +--- + +Add `header` to DataTable columns so consumers can customize header content with icons, styled text, and sort-aware UI. + +```tsx +column({ + label: "Amount", + sort: { field: "amount", type: "number" }, + header: (ctx) => + ctx.sortable ? ( + + ) : ( + ctx.label + ), +}); +``` + +Function headers receive a typed `HeaderRenderContext`: non-sortable columns only get `{ label, sortable: false }`, while sortable columns also get `sortDirection` and `activateSort()`. 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 68561208..2364229a 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -1,8 +1,9 @@ -import { afterEach, describe, it, expect, vi } from "vitest"; +import { afterEach, describe, it, expect, expectTypeOf, vi } from "vitest"; import { act, cleanup, render, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter } from "react-router"; import type { ReactNode } from "react"; import { createAppShellWrapper } from "../../../tests/test-utils"; +import type { CollectionControl } from "@/types/collection"; import { DataTable } from "./data-table"; import { useDataTable } from "./use-data-table"; import type { Column, DataTableData, RowAction, UseDataTableReturn } from "./types"; @@ -25,6 +26,30 @@ const testData: DataTableData = { ], }; +function makeControl(overrides?: Partial): CollectionControl { + return { + filters: [], + addFilter: vi.fn(), + setFilters: vi.fn(), + removeFilter: vi.fn(), + clearFilters: vi.fn(), + sortStates: [], + setSort: vi.fn(), + clearSort: vi.fn(), + pageSize: 10, + setPageSize: vi.fn(), + goToNextPage: vi.fn(), + goToPrevPage: vi.fn(), + resetPage: vi.fn(), + goToFirstPage: vi.fn(), + goToLastPage: vi.fn(), + resetCount: 0, + getHasPrevPage: () => false, + getHasNextPage: (pageInfo) => pageInfo.hasNextPage, + ...overrides, + }; +} + function TestDataTable(props: { columns?: Column[]; data?: DataTableData | undefined; @@ -114,6 +139,119 @@ describe("DataTable", () => { expect(container.querySelector('[data-slot="data-table-body"]')).toBeDefined(); }); + describe("custom headers", () => { + it("renders custom header content", () => { + const columns: Column[] = [ + { + label: "Name", + header: ( + <> + Customer + * + + ), + render: (row) => row.name, + }, + ]; + + render(, { wrapper }); + + expect(screen.getByText("Customer")).toBeDefined(); + expect(screen.getByText("*")).toBeDefined(); + expect(screen.queryByText("Name")).toBeNull(); + }); + + it("passes non-sortable context when sort config exists but sorting is inactive", () => { + let seenSortable: boolean | undefined; + const columns: Column[] = [ + { + label: "Name", + sort: { field: "name", type: "string" }, + header: (ctx) => { + seenSortable = ctx.sortable; + return ctx.sortable ? "sortable" : "static"; + }, + render: (row) => row.name, + }, + ]; + + render(, { wrapper }); + + expect(screen.getByText("static")).toBeDefined(); + expect(seenSortable).toBe(false); + }); + + it("passes sortable context and activateSort reuses the shared sort behavior", () => { + const control = makeControl({ + sortStates: [{ field: "name", direction: "Asc" }], + }); + + function Harness() { + const table = useDataTable({ + columns: [ + { + label: "Name", + sort: { field: "name", type: "string" }, + header: (ctx) => + ctx.sortable ? ( + + ) : null, + render: (row) => row.name, + }, + ], + data: testData, + control, + }); + + return ( + + + + ); + } + + render(, { wrapper }); + + fireEvent.click(screen.getByRole("button", { name: "Name Asc" })); + + expect(control.clearSort).toHaveBeenCalled(); + expect(control.setSort).toHaveBeenCalledWith("name", "Desc"); + }); + + it("narrows header render context by sortable", () => { + const column: Column = { + label: "Name", + header: (ctx) => { + if (ctx.sortable) { + expectTypeOf(ctx).toEqualTypeOf<{ + label?: string; + sortable: true; + sortDirection: "Asc" | "Desc" | undefined; + activateSort: () => void; + }>(); + } else { + expectTypeOf(ctx).toEqualTypeOf<{ + label?: string; + sortable: false; + }>(); + } + return ctx.label; + }, + render: (row) => row.name, + }; + + expect(column).toBeDefined(); + }); + }); + // ------------------------------------------------------------------------- // State exclusivity (loading / error / empty / data) // ------------------------------------------------------------------------- diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 75dc478b..59a04527 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -18,8 +18,7 @@ import { Table } from "@/components/table"; import { Button } from "@/components/button"; import { Menu } from "@/components/menu"; import { Tooltip } from "@/components/tooltip"; -import type { SortConfig } from "@/types/collection"; -import type { Column, RowAction, UseDataTableReturn } from "./types"; +import type { Column, HeaderRenderContext, RowAction, UseDataTableReturn } from "./types"; import { DataTableContext, type DataTableContextValue } from "./data-table-context"; import { useDataTableT } from "./i18n"; import { getCellValue, renderTypedCell } from "./cell-renderers"; @@ -567,17 +566,28 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } {ordered?.map((col) => { const key = keys.get(col) as string; const label = col.label; - - const isSortable = !!col.sort; - const currentSort = col.sort - ? sortStates?.find((s) => s.field === (col.sort as SortConfig).field) + const sortField = col.sort?.field; + const currentSort = sortField + ? sortStates?.find((s) => s.field === sortField) : undefined; + const isSortable = !!sortField && !!onSort; - const handleClick = () => { - if (!isSortable || !onSort || !col.sort) return; - onSort(col.sort.field, nextSortDirection(currentSort?.direction)); + const activateSort = () => { + if (!sortField || !onSort) return; + onSort(sortField, nextSortDirection(currentSort?.direction)); }; + const headerContext: HeaderRenderContext = isSortable + ? { + label, + sortable: true, + sortDirection: currentSort?.direction, + activateSort, + } + : { label, sortable: false }; + const content = + typeof col.header === "function" ? col.header(headerContext) : (col.header ?? label); + const align = resolveAlign(col); const { style, className } = pinCellProps( placements.get(col), @@ -596,7 +606,7 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } data-col-key={key} style={style} className={className} - onClick={isSortable ? handleClick : undefined} + onClick={isSortable ? activateSort : undefined} > - {label} + {content} {currentSort && } diff --git a/packages/core/src/components/data-table/index.ts b/packages/core/src/components/data-table/index.ts index 97e3d23b..10ac3676 100644 --- a/packages/core/src/components/data-table/index.ts +++ b/packages/core/src/components/data-table/index.ts @@ -15,6 +15,7 @@ export type { ColumnBase, ColumnCellType, ColumnTypeBranch, + HeaderRenderContext, DataTableData, DateCellOptions, LinkCellOptions, diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index dfab8c52..3be2820e 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -83,13 +83,39 @@ export interface LinkCellOptions> { href: (row: TRow) => string | null | undefined; } +/** + * Header render context for non-sortable columns. + */ +interface NonSortableHeaderRenderContext { + label?: string; + sortable: false; +} + +/** + * Header render context for sortable columns. + */ +interface SortableHeaderRenderContext { + label?: string; + sortable: true; + sortDirection: "Asc" | "Desc" | undefined; + activateSort: () => void; +} + +/** + * Context passed to custom `header` renderers. + */ +export type HeaderRenderContext = NonSortableHeaderRenderContext | SortableHeaderRenderContext; + /** * Fields shared by every `Column` regardless of `type`. Prefer `Column` * in most cases; this is exported so consumers can compose more specific * column types (e.g. `type MoneyColumn = ColumnBase & { type: "money"; … }`). */ export interface ColumnBase> { - /** Column header text. Omit for action or icon-only columns. */ + /** + * Column header text. Used as the default header content and passed to + * custom `header` renderers. + */ label?: string; /** * Renders the cell content for a given row. Optional — when omitted, the @@ -101,6 +127,14 @@ export interface ColumnBase> { * across spread-then-override patterns like `column({ ...inferred, render })`. */ render?: (row: TRow) => ReactNode; + /** + * Custom header content. + * + * Pass a `ReactNode` for static decoration (icons, highlighted text, etc.), + * or a function to react to sort state while keeping the surrounding header + * cell's built-in sort behavior. + */ + header?: ReactNode | ((ctx: HeaderRenderContext) => ReactNode); /** * Stable identifier used for column visibility toggling and as the React key. * Falls back to `label` when omitted. Set this explicitly when `label` is diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 65b43784..b6d7c52f 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -229,6 +229,7 @@ export { type DataTableRootProps, type Column, type DataTableData, + type HeaderRenderContext, type RowAction, type UseDataTableOptions, type UseDataTableReturn, From 232c6f4567e2621c351ead69b4ebed86cf14b3ce Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Fri, 24 Jul 2026 14:14:20 +0900 Subject: [PATCH 2/3] fix(data-table): avoid double sort in custom headers --- .changeset/quick-wombats-judge.md | 2 +- .../src/components/data-table/data-table.test.tsx | 11 +++-------- .../core/src/components/data-table/data-table.tsx | 9 +++++---- 3 files changed, 9 insertions(+), 13 deletions(-) diff --git a/.changeset/quick-wombats-judge.md b/.changeset/quick-wombats-judge.md index 41d3e510..2eae573e 100644 --- a/.changeset/quick-wombats-judge.md +++ b/.changeset/quick-wombats-judge.md @@ -19,4 +19,4 @@ column({ }); ``` -Function headers receive a typed `HeaderRenderContext`: non-sortable columns only get `{ label, sortable: false }`, while sortable columns also get `sortDirection` and `activateSort()`. +Function headers receive a typed `HeaderRenderContext`: non-sortable columns only get `{ label, sortable: false }`, while sortable columns also get `sortDirection` and `activateSort()`. When you provide a function header, that renderer owns the sort click surface via `ctx.activateSort()`. 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 2364229a..6f538be0 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -194,13 +194,7 @@ describe("DataTable", () => { sort: { field: "name", type: "string" }, header: (ctx) => ctx.sortable ? ( - ) : null, @@ -222,7 +216,8 @@ describe("DataTable", () => { fireEvent.click(screen.getByRole("button", { name: "Name Asc" })); - expect(control.clearSort).toHaveBeenCalled(); + expect(control.clearSort).toHaveBeenCalledTimes(1); + expect(control.setSort).toHaveBeenCalledTimes(1); expect(control.setSort).toHaveBeenCalledWith("name", "Desc"); }); diff --git a/packages/core/src/components/data-table/data-table.tsx b/packages/core/src/components/data-table/data-table.tsx index 0af5331d..b04999dc 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -571,8 +571,9 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } activateSort, } : { label, sortable: false }; - const content = - typeof col.header === "function" ? col.header(headerContext) : (col.header ?? label); + const isCustomHeader = typeof col.header === "function"; + const content = isCustomHeader ? col.header(headerContext) : (col.header ?? label); + const enableDefaultHeaderClick = isSortable && !isCustomHeader; const align = resolveAlign(col); const { style, className } = pinCellProps( @@ -580,7 +581,7 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } { style: col.width ? { width: col.width } : undefined, className: cn( - isSortable && "astw:cursor-pointer astw:select-none", + enableDefaultHeaderClick && "astw:cursor-pointer astw:select-none", align === "right" && "astw:text-right", ), }, @@ -592,7 +593,7 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } data-col-key={key} style={style} className={className} - onClick={isSortable ? activateSort : undefined} + onClick={enableDefaultHeaderClick ? activateSort : undefined} > Date: Fri, 24 Jul 2026 14:38:11 +0900 Subject: [PATCH 3/3] refactor(data-table): unify header renderer behavior --- .changeset/quick-wombats-judge.md | 2 +- .../components/data-table/data-table.test.tsx | 38 +++++++++++++ .../src/components/data-table/data-table.tsx | 56 +++++++++++-------- .../core/src/components/data-table/types.ts | 7 ++- 4 files changed, 76 insertions(+), 27 deletions(-) diff --git a/.changeset/quick-wombats-judge.md b/.changeset/quick-wombats-judge.md index 2eae573e..5b1a9ae9 100644 --- a/.changeset/quick-wombats-judge.md +++ b/.changeset/quick-wombats-judge.md @@ -19,4 +19,4 @@ column({ }); ``` -Function headers receive a typed `HeaderRenderContext`: non-sortable columns only get `{ label, sortable: false }`, while sortable columns also get `sortDirection` and `activateSort()`. When you provide a function header, that renderer owns the sort click surface via `ctx.activateSort()`. +Plain `ReactNode` headers keep the built-in sortable header renderer. Function headers receive a typed `HeaderRenderContext`: non-sortable columns only get `{ label, sortable: false }`, while sortable columns also get `sortDirection` and `activateSort()`. When you provide a function header, that renderer owns the sort click surface and sort UI via `ctx.activateSort()`. 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 e29830e9..7f8a6cff 100644 --- a/packages/core/src/components/data-table/data-table.test.tsx +++ b/packages/core/src/components/data-table/data-table.test.tsx @@ -161,6 +161,44 @@ describe("DataTable", () => { expect(screen.queryByText("Name")).toBeNull(); }); + it("keeps built-in sort behavior for sortable ReactNode headers", () => { + const control = makeControl(); + + function Harness() { + const table = useDataTable({ + columns: [ + { + label: "Name", + sort: { field: "name", type: "string" }, + header: ( + <> + Customer + * + + ), + render: (row) => row.name, + }, + ], + data: testData, + control, + }); + + return ( + + + + ); + } + + render(, { wrapper }); + + fireEvent.click(screen.getByRole("button", { name: "Customer" })); + + expect(control.clearSort).toHaveBeenCalledTimes(1); + expect(control.setSort).toHaveBeenCalledTimes(1); + expect(control.setSort).toHaveBeenCalledWith("name", "Asc"); + }); + 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 f19de768..0c91026f 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -50,6 +50,28 @@ function nextSortDirection(current: string | undefined): "Asc" | "Desc" | undefi return "Asc"; } +function renderDefaultHeader( + content: ReactNode, + ctx: HeaderRenderContext, + align: "left" | "right", +): ReactNode { + if (!ctx.sortable) return content; + + return ( + + ); +} + // ============================================================================= // Column pinning (sticky columns) // ============================================================================= @@ -571,39 +593,27 @@ function DataTableHeaders({ className: headerClassName }: { className?: string } activateSort, } : { label, sortable: false }; - const isCustomHeader = typeof col.header === "function"; - const content = isCustomHeader ? col.header(headerContext) : (col.header ?? label); - const enableDefaultHeaderClick = isSortable && !isCustomHeader; const align = resolveAlign(col); + const header = col.header; + const renderHeader = + typeof header === "function" + ? header + : (renderContext: HeaderRenderContext) => + renderDefaultHeader(header ?? label, renderContext, align); + const content = renderHeader(headerContext); + const { style, className } = pinCellProps( placements.get(col), { style: col.width ? { width: col.width } : undefined, - className: cn( - enableDefaultHeaderClick && "astw:cursor-pointer astw:select-none", - align === "right" && "astw:text-right", - ), + className: cn(align === "right" && "astw:text-right"), }, "header", ); return ( - - - {content} - {currentSort && } - + + {content} ); })} diff --git a/packages/core/src/components/data-table/types.ts b/packages/core/src/components/data-table/types.ts index 3be2820e..ad176a47 100644 --- a/packages/core/src/components/data-table/types.ts +++ b/packages/core/src/components/data-table/types.ts @@ -130,9 +130,10 @@ export interface ColumnBase> { /** * Custom header content. * - * Pass a `ReactNode` for static decoration (icons, highlighted text, etc.), - * or a function to react to sort state while keeping the surrounding header - * cell's built-in sort behavior. + * Pass a `ReactNode` to replace the label while keeping the built-in header + * renderer (including its sortable button behavior). Pass a function to + * fully control the header UI; sortable function headers receive + * `activateSort()` and own the sort click surface. */ header?: ReactNode | ((ctx: HeaderRenderContext) => ReactNode); /**