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,