diff --git a/.changeset/quick-wombats-judge.md b/.changeset/quick-wombats-judge.md new file mode 100644 index 00000000..5b1a9ae9 --- /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 + ), +}); +``` + +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 bf47ab53..7f8a6cff 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,152 @@ 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("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[] = [ + { + 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).toHaveBeenCalledTimes(1); + expect(control.setSort).toHaveBeenCalledTimes(1); + 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 4c547f71..0c91026f 100644 --- a/packages/core/src/components/data-table/data-table.tsx +++ b/packages/core/src/components/data-table/data-table.tsx @@ -17,8 +17,7 @@ import { Button } from "@/components/button"; import { Checkbox } from "@/components/checkbox"; 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"; @@ -51,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) // ============================================================================= @@ -553,46 +574,46 @@ 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 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( - isSortable && "astw:cursor-pointer astw:select-none", - align === "right" && "astw:text-right", - ), + className: cn(align === "right" && "astw:text-right"), }, "header", ); return ( - - - {label} - {currentSort && } - + + {content} ); })} 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..ad176a47 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,15 @@ export interface ColumnBase> { * across spread-then-override patterns like `column({ ...inferred, render })`. */ render?: (row: TRow) => ReactNode; + /** + * Custom header content. + * + * 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); /** * 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 8bb77a79..d136eaa0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -230,6 +230,7 @@ export { type DataTableRootProps, type Column, type DataTableData, + type HeaderRenderContext, type RowAction, type UseDataTableOptions, type UseDataTableReturn,