Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/quick-wombats-judge.md
Original file line number Diff line number Diff line change
@@ -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 ? (
<button type="button" onClick={ctx.activateSort}>
{ctx.label} {ctx.sortDirection === "Asc" ? "▲" : ctx.sortDirection === "Desc" ? "▼" : null}
</button>
) : (
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()`.
173 changes: 172 additions & 1 deletion packages/core/src/components/data-table/data-table.test.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -25,6 +26,30 @@ const testData: DataTableData<TestRow> = {
],
};

function makeControl(overrides?: Partial<CollectionControl>): 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<TestRow>[];
data?: DataTableData<TestRow> | undefined;
Expand Down Expand Up @@ -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<TestRow>[] = [
{
label: "Name",
header: (
<>
<span>Customer</span>
<span aria-hidden>*</span>
</>
),
render: (row) => row.name,
},
];

render(<TestDataTable columns={columns} />, { 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<TestRow>({
columns: [
{
label: "Name",
sort: { field: "name", type: "string" },
header: (
<>
<span>Customer</span>
<span aria-hidden>*</span>
</>
),
render: (row) => row.name,
},
],
data: testData,
control,
});

return (
<DataTable.Root value={table}>
<DataTable.Table />
</DataTable.Root>
);
}

render(<Harness />, { 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<TestRow>[] = [
{
label: "Name",
sort: { field: "name", type: "string" },
header: (ctx) => {
seenSortable = ctx.sortable;
return ctx.sortable ? "sortable" : "static";
},
render: (row) => row.name,
},
];

render(<TestDataTable columns={columns} />, { 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<TestRow>({
columns: [
{
label: "Name",
sort: { field: "name", type: "string" },
header: (ctx) =>
ctx.sortable ? (
<button type="button" onClick={ctx.activateSort}>
{ctx.label} {ctx.sortDirection}
</button>
) : null,
render: (row) => row.name,
},
],
data: testData,
control,
});

return (
<DataTable.Root value={table}>
<DataTable.Table />
</DataTable.Root>
);
}

render(<Harness />, { 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<TestRow> = {
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)
// -------------------------------------------------------------------------
Expand Down
79 changes: 50 additions & 29 deletions packages/core/src/components/data-table/data-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 (
<button
type="button"
onClick={ctx.activateSort}
className={cn(
"astw:flex astw:w-full astw:cursor-pointer astw:select-none astw:items-center astw:gap-1 astw:border-0 astw:bg-transparent astw:p-0 astw:text-inherit astw:outline-none astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px]",
align === "right" ? "astw:justify-end astw:text-right" : "astw:text-left",
)}
>
{content}
{ctx.sortDirection && <SortIndicator direction={ctx.sortDirection} />}
</button>
);
}

// =============================================================================
// Column pinning (sticky columns)
// =============================================================================
Expand Down Expand Up @@ -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 (
<Table.Head
key={key}
data-col-key={key}
style={style}
className={className}
onClick={isSortable ? handleClick : undefined}
>
<span
className={cn(
"astw:inline-flex astw:items-center astw:gap-1",
align === "right" && "astw:justify-end",
)}
>
{label}
{currentSort && <SortIndicator direction={currentSort.direction} />}
</span>
<Table.Head key={key} data-col-key={key} style={style} className={className}>
{content}
</Table.Head>
);
})}
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/components/data-table/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export type {
ColumnBase,
ColumnCellType,
ColumnTypeBranch,
HeaderRenderContext,
DataTableData,
DateCellOptions,
LinkCellOptions,
Expand Down
37 changes: 36 additions & 1 deletion packages/core/src/components/data-table/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +83,39 @@ export interface LinkCellOptions<TRow extends Record<string, unknown>> {
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<TRow>`
* in most cases; this is exported so consumers can compose more specific
* column types (e.g. `type MoneyColumn<TRow> = ColumnBase<TRow> & { type: "money"; … }`).
*/
export interface ColumnBase<TRow extends Record<string, unknown>> {
/** 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
Expand All @@ -101,6 +127,15 @@ export interface ColumnBase<TRow extends Record<string, unknown>> {
* 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
Expand Down
Loading
Loading