@akshar-technosoft/ui
v1.3.1
Published
A complete end to end package made for handling complexity of layout and user interfaces of react application
Readme
@akshar-technosoft/ui
Type-safe React component library for data-heavy ERP screens: a full-featured DataTable, a card/grid DataTemplate, a composable page-layout system and utility components. Built on TypeScript, Tailwind CSS v4, TanStack Table v8 and Radix.
npm install @akshar-technosoft/uiColors ride the app's theme CSS variables (--sidebar, --border, --background…), so the library reskins with the app theme automatically. The consuming app's Tailwind must scan this package (via @source or content config) so the classes compile.
Exports at a glance
| Export | What it is |
|---|---|
| DataTable, DataTableProps, GroupColumnDef | The data table and its types |
| DataTableActionBar, DataTableActionBarAction, DataTableActionBarSelection | Floating bulk-action bar for selected rows |
| getTableExportView, TableExportView, TableExportColumn | Export snapshot builder (see Export) |
| readPersistedExternal, useTableFilterParam, FilterKeys, PersistConfig, PersistGroupRegistry, PersistGroupId, PersistSlotId, PersistSlotFor | Persist + URL-sync helpers (see Persist) |
| DataTemplate, DataTemplateProps + action-bar components | Card/grid sibling of DataTable |
| AppContainer, AppHeader, AppContent, AppContentHeader, AppContentFooter, AppSheet, AppFooter | Page layout system |
| GeneralHelper | Static utils: formatDate, formatINR, toProperCase, copyToClipboard, CopyToClipboard, StatusBadge |
| Loader, ErrorComponent, NotFound, Unauthorized, OfflineUI, CurrencyTransfer | Full-area state screens |
The JSDoc in data-table-types.ts is the authoritative per-prop reference; hover any prop in the IDE. This README explains how the pieces fit and the recipes that aren't obvious from types.
DataTable
A client-side table: hand it data, it filters/sorts/paginates in memory. Server-side filtering is opt-in per filter (see External filters). Everything is plain props — no context, no provider.
Quick start
<DataTable
data={items}
columns={columns}
settings={{ showToolbar: true }}
pagination={{}}
filters={[{ key: "Status", type: "select", placeholder: "Status", options: statusOptions }]}
getRowId={(row) => String(row.Code)}
/>Columns
Columns are TanStack ColumnDefs plus extras (GroupColumnDef). The three identity fields, and when each is needed:
| Field | What it does | When you need it |
|---|---|---|
| accessorKey | Points at a data field (dot paths like "Party.Name" work). Becomes the column id AND the value source for sorting, filtering, global search and export. | The normal case: the column shows one field. |
| accessorFn | Computes the column's value from the whole row. | The cell renders a composite (several fields together) but sorting / global search / export should still see it. |
| id | Explicit identity string. | Required when there is no accessorKey (display-only or accessorFn columns). It's what filters, aggregations and the Columns menu reference. |
cell controls rendering only. A column with just id + cell (an actions column, a JSX-composed cell) is invisible to sorting, global search and export — right for actions, usually wrong for composite data cells.
Recipe — composite cell that global search can find. A cell printing LotNo, SKU and Category together: give the column an accessorFn joining them. No filter config needed — global search matches accessor values.
{
id: "lotInfo",
accessorFn: (row) => `${row.LotNo} ${row.SKU} ${row.Category}`,
header: "Lot / SKU",
cell: ({ row }) => <LotCell data={row.original} />,
}Extras on GroupColumnDef:
headerAlign: 'left' | 'center' | 'right'— header alignment (body cell alignment is yourcell's business).columns: [...]— nested columns under a grouped header row.enablePinning: false— remove this column from the pin menu.enableColumnVisibility: false— per-column override forsettings.enableColumnVisibility; keeps this one column out of the Columns menu and the header⋯menu's Hide entry, table-wide toggling stays on for the rest. Same field as TanStack's nativeenableHiding— set either, not both.enableHeaderMenu: false— hide the header's⋯options menu on this column entirely, regardless of what sorting/pinning/hiding would otherwise show. For a column that shouldn't offer the menu at all (an actions column, say) instead of disabling sorting and pinning separately just to make the dots go away.reserveMenuSpace: true— reserve real space for the sort chevron /⋯menu instead of letting them float as a hover-only overlay. Opt in only for a column with a custom interactiveheader(your own button) that the overlay would otherwise sit on top of — everywhere else hover reserves nothing on purpose (no layout shift).customFooter: { content, align, className }— a custom footer cell;contentmay be(table) => ReactNode. Beats the column's aggregation display.exportValue/exportHeader— see Export.size— fixed width in px; without it the column auto-sizes. Columns/Aggregations menus derive display names from the column id (Party_Name→ "Party Name").
Sorting
On by default (settings.enableSorting, table-wide). Clicking a sortable header cycles asc → desc → clear; the header's ⋯ menu offers Sort Ascending / Sort Descending / Clear Sort explicitly (the active one disabled). The active direction shows as an arrow at the header's right edge; the ⋯ button and a faint sort hint appear on hover in a gradient overlay (no space is reserved, no text shifts). Clicks on interactive elements inside custom JSX headers do not sort.
Two levels, both named enableSorting, and they AND together — table-level can't be overridden back on per-column:
settings.enableSorting: false(table-level) kills sorting everywhere — no column can re-enable itself withenableSorting: true.- A column's own
enableSorting: falseturns sorting off for just that column, table-wide sorting stays on for the rest. This is the normal way to make specific columns unsortable — leavesettings.enableSortingalone (default on) and opt individual columns out, not the other way around.
The serial "No." column sorts by display index — rarely meaningful.
Filters — the full story
The filters prop renders toolbar controls. Filters live in the toolbar, so nothing renders without settings.showToolbar: true (a dev warning fires) — the most common "my filter is missing" cause.
A filter takes one of two shapes:
1. Path filter — key (the common case)
key is a dot path into the row data (validated against your row type). It is NOT a column id, although the two often coincide. What happens depends on whether a column already owns that path:
- A column with the same
accessorKeyexists → the filter drives that column. Zero extra setup. - No column owns the path → the table auto-generates a hidden filter-only column for it. You never touch
columns.
filters={[
{ key: "PartyDetails.Name", placeholder: "Party" }, // text contains (default)
{ key: "Status", type: "select", placeholder: "Status", options },
{ key: "Date", type: "daterange", placeholder: "Date", externalSearch: true },
]}Hidden filter-only column internals (why they're free): they never render a cell (no render cost), do work only while their filter holds a value, are excluded from global search (no matches on data the user can't see) and from the Columns menu.
2. Custom filter — id + filterFn
When the match is not a plain contains on one field — a cell holding an array of objects, or one control matching across several fields — you own the predicate. filterFn(row, value) receives the full row original and returns whether it passes. id is any unique string (need not be a real field). It also rides a hidden filter-only column and AND-combines with everything else.
{
id: "lotNo", type: "text", placeholder: "Lot No",
filterFn: (row, v) =>
row.OrderCodeJson.some(l => String(l.LotNo).toLowerCase().includes(String(v).toLowerCase())),
}Recipe — one cell, two different filters
A cell shows Party and City together; you want independent Party and City filters. The cell is one column, but filters target data paths, not columns — declare two filters and you're done:
filters={[
{ key: "Party.Name", placeholder: "Party" },
{ key: "Party.City", placeholder: "City" },
]}Each rides its own hidden filter-only column (or a real column if one owns the path). They AND-combine. The visible cell is untouched. If the cell should ALSO match in global search, that's the separate accessorFn recipe under Columns.
Required vs optional
Required: placeholder, plus either key (path filter) or id + filterFn (custom filter). Everything else defaults:
| Field | Default | Notes |
|---|---|---|
| type | "text" | text (case-insensitive contains) · select (needs options, matched exactly) · date / daterange (picker, dateFormat for display) · number |
| placement | "bar" | "menu" collapses the control into the global search's field picker; while active it shows a removable chip (settings.showFilterChips) |
| externalSearch | false | Run on the server instead (below) |
| width, icon | — | Cosmetic (width = Tailwind class on the control wrapper) |
| shared, persist | — / true | See Persist |
External (server) filters
externalSearch: true = the value is never applied to in-memory rows; all staged external values are handed to onExternalSearch(values) so you re-query the API. Commit points are the control's natural ones: select fires on pick, date/daterange on Apply, text after a 500ms debounce. values is a map keyed by filter identity (key/id).
onExternalSearch={(values) => setSearchFilters(values)} // feed your query paramsWhy one table-level callback and not per-filter? A server query is ONE request carrying all params. Every commit hands you the complete staged map — with two external filters (say Date and PartyName), picking a date fires { Date: {...} }, then typing a party fires { Date: {...}, PartyName: "xy" }: both values together, every time. The page builds params in one place and runs one query; any number of external filters just adds keys to the same map. Clearing fires {}.
Local and external filters mix freely on one table: external narrows what you fetch, local narrows the fetched rows.
Global search & the field picker
enableGlobalFilter (default on) renders the search box. It matches accessor columns only — a cell-only column is invisible to it (see the accessorFn recipe). It needs enableFiltering (the engine) to actually remove rows.
When any filter has placement: 'menu', a field picker appears on the search box automatically: "All Fields" plus each menu filter. Picking a field swaps the free-text box for that filter's native typed control (select/date/etc.) and clears the free text so the two don't stack. settings.groupedFilterLabel renames the "all" label. Menu-placement filters with enableGlobalFilter off are unreachable (dev warning).
Clearing
A ghost "Clear Filters" button appears next to the controls whenever anything filters rows (column filters, global search, staged external values); the Settings menu has the same action. Clearing resets local filters, global search text, external values, the field picker AND the persisted snapshot (including this table's shared slots).
Pagination
The pagination prop's presence enables it — {} for defaults, omit to render all rows.
pagination={{ pageSize: 25, pageSizeOptions: [10, 25, 50, -1], showPageNumbers: true }}Defaults: pageSize: 10, pageSizeOptions: [10, 25, 50, 100, -1]. -1 renders "All"; [] hides the size selector. showInfo (default true) prints "Showing X to Y of Z". showPageNumbers (default false) adds numbered buttons with an ellipsis window; otherwise prev/next only.
Selection
enableRowSelection (default on) adds the leading checkbox column.
selectionMode: "multiple"(default) — checkboxes + a select-all header checkbox (indeterminate when partial;enableHeaderCheckbox: falsegreys it while per-row stays usable)."single"— radio, whole row clickable, previous selection cleared;showCheckbox: falsehides the radio but keeps the row clickable.isRowSelectable={(row) => boolean}— failing rows grey out, select-all skips them,onRowSelectionChangenever reports them.onRowSelectionChange={(rows) => …}— selected row originals on every change.defaultSelectedRows={(row) => boolean}— uncontrolled prefill, seeded once after the first non-empty data load, selectable rows only; re-seed by remounting with a Reactkey.
getRowId — read this if selection ever "moves" to the wrong rows. Without it, selection is keyed by array index: any refetch/reorder/insert makes index 2 a different record, and the wrong rows light up. Point it at your record's identity — required for reliable defaultSelectedRows too:
getRowId={(row) => String(row.Code)}
getRowId={(row) => `${row.Code}-${row.Type}`} // unique only as a pairBulk actions on selected rows — the action bar via the render-prop. It portals to document.body, floats bottom-center, auto-shows while anything is selected, and Escape clears the selection:
<DataTable ...>
{(table) => (
<DataTableActionBar table={table}>
<DataTableActionBarSelection table={table} /> {/* "N selected" + clear */}
<DataTableActionBarAction tooltip="Issue" isPending={busy} onClick={...}>
Issue
</DataTableActionBarAction>
</DataTableActionBar>
)}
</DataTable>Aggregations
Parent-controlled footer computations over the filtered rows: sum, average, min, max, count, unique, median.
const [aggs, setAggs] = useState<AggregationConfigMap<Row>>({
Qty: { enabled: true, functions: ["sum"] },
});
<DataTable
aggregations={aggs}
onAggregationChange={(colId, config) => setAggs(prev => ({ ...prev, [colId]: config }))}
/>The sticky footer row appears when any config is enabled; the serial column's footer cell then shows the total row count. Non-numeric cells are dropped (not coerced to 0). config.accessor overrides the value source; config.format(value, func) overrides display (default: counts as integers, others 2 decimals). A column's customFooter wins over its aggregation display, and the header shows a small calculator icon on aggregated columns.
The toolbar's Aggregations menu (user-facing toggles, per-column function submenu) renders only with onAggregationChange + showToolbar + at least one aggregatable column — auto-detected by sampling the first rows for numeric values, or forced with meta: { aggregatable: true } on the column.
Pinning, visibility, resizing
- Pinning (
enableColumnPinning, default on): per-column via the header⋯menu (Pin Right / Unpin), bulk reset in the Settings menu. Serial and select columns are always pinned left and re-asserted on every change. A pinned column is clamped to its live rendered width so sticky offsets stay true. - Visibility (
settings.enableColumnVisibility, default on): the toolbar Columns menu toggles columns (grouped columns listed under their group); the header⋯menu offers Hide. Un-hiding lives only in the Columns menu — a hidden column has no header to click. Per-columnenableColumnVisibility: false(or TanStack's nativeenableHiding: false, same thing) keeps that one column out of all of it. - Header menu (
⋯): appears whenever a column can sort, pin, or hide — whichever of those is on drives it, nothing to configure directly.enableHeaderMenu: falseon a column suppresses the⋯unconditionally, even if sort/pin/hide are on; the sort chevron and click-to-sort are unaffected (that'senableSorting's call, not the menu's). - Resizing (
enableColumnResizing, default OFF): live drag handles on header right edges. Pinned columns are size-locked — resize first, pin after.
Persist
Opt-in state persistence across unmount/back-navigation, and the URL query string:
persist="qc-completed" // shorthand for { key }
persist={{ key: "qc-completed", group: "inhouse", local: true }}Persists: column filter values, global search text, the field-picker selection, external (server) filter values, pagination (pagination: false to skip page state). Never persists: selection, sorting, visibility, pinning, sizing.
Three legs, one consistency rule: in-memory (always, whenever persist is set) + the URL query string (always) + localStorage (opt-in via local: true). While a persist-enabled table is mounted, mem and the URL always mirror each other — mem is the primary; local: true folds localStorage into the same guarantee. This is what makes each of the following work without you wiring anything:
- Filter something, navigate away (even a bare nav-link click to the same route, no remount), come back — filter AND url both restored, always in sync.
- Filter something, hit refresh — the URL alone survives it, no
localneeded. - Share/bookmark a URL with
?LotNo=X— deep-links straight into a pre-filtered table. local: trueadditionally survives a full close/reopen of the browser (URL alone doesn't).
The one protocol you should know if you're driving the URL yourself (useTableFilterParam, a manual setSearchParams, a <TypedLink> with a query): an absent param is never treated as a delete — a bare navigation naturally drops the query string without the table's state changing, so on remount/re-render a missing param gets restored into the URL from mem, not treated as "user cleared it." To actually clear a filter from outside the table, write the param present but empty (?LotNo=) — useTableFilterParam's setter already does this for you.
How it works otherwise: values are stored per filter identity (key/id), not as raw table state, in an in-memory map — a refresh with no local starts clean except for whatever the URL still carries. local: true mirrors to localStorage (atsui:tbl:v1:*); note it's per-browser, not per-login, and not live across tabs (another already-open tab picks up a change on its next mount/navigation, not instantly). Restore is synchronous, seeded before first render — no flash of unfiltered data. Dates serialize as one URL param, ISO, comma-separated for a range (?Date=2026-07-01,2026-07-09). Per-filter opt-out: persist: false on that filter (excluded from mem, localStorage, AND the URL).
key must be app-unique. Two mounted tables on one key overwrite each other's state (dev warning). One page with two tables = two keys. Two DIFFERENT tables sharing a filter id (or both using the global search) on the SAME route will cross-talk through the URL, since query params are one flat namespace per page — usually not a real scenario since that'd mean two tables on one screen filtering the same field.
Reading/writing a table's filter from OUTSIDE it
useTableFilterParam(filters, key) gives you a live, typed [value, setValue] for one filter — typed straight off the same filters array you already pass to <DataTable>, no separate registry to keep in sync. Works because of the URL-mirror guarantee above: reading the URL param IS reading the filter's live value.
const [lotNo, setLotNo] = useTableFilterParam(lotReportFilters, "LotNo")
// lotNo: string | undefined — live, updates as the table's own filter changes
// setLotNo(undefined) clears it (writes the present-but-empty param, not a delete)Shared slots — one value across pages
shared: "party" on a filter publishes its VALUE into a named slot; every table in the same persist.group with a filter bound to that slot picks it up — even when the pages use different filter keys, or one side is an externalSearch filter (values are stored by identity, which is what makes this possible). Search a party on one page; the next page in the group opens already filtered to it. Two tables mounted at once live-sync through the slot. Shared filters also go through the URL leg (a redirect from OUTSIDE the group has no other way to reach a shared filter), so the same absent-vs-empty protocol above applies to them too.
shared REQUIRES persist.group. Slots never cross groups — "party" in group inhouse is not "party" in sales. A shared filter on a group-less table is inert and warns.
Typing group / shared — the registry file
Augment PersistGroupRegistry once and both become validated unions instead of plain strings:
// src/types/table-persist.d.ts
declare module "@akshar-technosoft/ui" {
interface PersistGroupRegistry {
inhouse: "party" | "material";
sales: "party";
}
}After this group: "inhose" is a compile error, and shared autocompletes/type-checks against only that one table's own group's slots — not the app-wide union of every group's slots. That narrowing is automatic when filters is written inline in JSX alongside persist:
<DataTable
persist={{ key: "qc-completed", group: "inhouse" }}
filters={[
{ key: "PartyCode", placeholder: "Party", shared: "party" }, // ✓ autocompletes "party" | "material"
]}
/>It does NOT auto-narrow when filters is a separately declared const — the common pattern in this codebase — because that array gets its type at its own declaration, independent of a persist prop written later in the same component. Pass the group as DataTableProps's second type parameter there:
// QualityCheckPending.tsx — table's persist.group is "QC"
const filters: DataTableProps<ORPReportQualityCheckType, "QC">["filters"] = [
{ key: "Code", placeholder: "Taka No.", type: "text" },
{ key: "RefNo", placeholder: "Ref No", type: "text", shared: "taka" }, // ← now autocompletes/checks against ONLY group "QC"'s slots ("taka"), not every group's
// ...
]Without the , "QC" the array still compiles (falls back to the broad union of every group's slots, same as before this existed) — the second type param is what turns that into a real, group-scoped check.
The double API call, and how to fix it
With persisted external filters, mount order is: the page fires its default query → the table replays restored external values through onExternalSearch → the page queries AGAIN with filters. Two requests, the first wasted.
Fix A (proper): seed the page's initial query params from the persisted values, so the FIRST request already carries them:
import { readPersistedExternal } from "@akshar-technosoft/ui";
// before — default query fires unfiltered, then re-fires filtered
const [params, setParams] = useState({ Status: "Pending" });
// after — first query is already filtered
const [params, setParams] = useState(() => ({
Status: "Pending",
...readPersistedExternal("qc-completed"), // same key as the table's persist
}));The mount replay then commits the same values the query already used; with React Query the identical query key dedupes, and a manual fetch effect should compare params before refetching.
Fix B (pragmatic): don't persist that filter — persist: false on the external filter. The rest of the table still persists; the server filter starts clean each visit. Right when the double call is cheaper than the wiring.
Export
The table never writes files. It hands you a clean snapshot and the app owns the writer — per-page, fully customizable Excel/CSV builders, no spreadsheet dependency inside the library:
onExport={(data, view) => buildQcExcel(view)} // Settings menu > Export (item appears only when onExport is passed)
// or anywhere you hold the table instance (onTableReady / children render-prop):
const view = getTableExportView(table);What the two arguments are:
data: T[]— the filtered + sorted row originals (your objects, all pages, not just the visible one). It is literallyview.originals— kept as the first arg so simple handlers can ignoreview.view: TableExportView<T>— the ready-to-write snapshot:
{
columns: [ // VISIBLE columns only, in display + pin order,
{ id: "FullCPOSrNo", header: "Order No." }, // select/serialNumber excluded;
{ id: "Qty", header: "Qty" }, // header = exportHeader ?? string header ?? id
],
rows: [ // one array per row, values in `columns` order,
["CPO-101", 250], // native types kept (number/Date/boolean — real
["CPO-102", 480], // Excel cells, not strings)
],
originals: [ {...}, {...} ], // same rows as objects, same order as `rows`
}Rows come from the pre-pagination model: filtered and sorted, ALL pages. Typical writer: header row from view.columns.map(c => c.header), data rows straight from view.rows; reach into view.originals when the sheet needs fields that aren't visible columns.
A column is included when it has an accessor or exportValue. Display-only columns are skipped unless exportValue supplies a flat value — also the tool for JSX composite cells:
exportValue: (row) => `${row.RefNo} (${GeneralHelper.formatDate(row.RefDate)})`,
exportHeader: "Reference", // needed only when `header` is JSXToolbar & the Settings menu
showToolbar (default OFF) is the largest master switch — it hosts the bar filters + chips + Clear Filters, the global search with field picker, the Aggregations menu, the Columns menu, the Settings menu and Refresh (onRefresh, disabled while loading). The header's per-column ⋯ menus work without it. The whole toolbar is one wrap row: overflowing controls drop line by line, the right cluster stays right-aligned.
toolbarPosition: 'above-header' | 'below-header' moves the toolbar inside the card as a sticky band that stacks with the header prop (band heights are measured at runtime for correct sticky offsets).
Settings (gear) menu: Export (when onExport given) · Clear All Sorting · Clear All Filters · Clear Aggregations · Reset All Pinning — each disabled while there's nothing to clear.
Settings reference
Taxonomy: show* = a UI element/region is visible · enable* = a capability is on · bare adjectives = presentation.
| Setting | Default | Notes |
|---|---|---|
| showHeader | true | Off also removes sorting/pin/resize (they live in the header) |
| showToolbar | false | Master for search/filters/menus (above) |
| showSerialNumbers | true | "No." column — position in current view, not a record id |
| showCheckbox | true | Single-mode radio visibility (row stays clickable) |
| showFilterChips | true | Chips for active placement:'menu' filters |
| enableSorting | true | Header click cycle + ⋯ menu |
| enableFiltering | true | The engine; off = boxes render but no rows are removed |
| enableGlobalFilter | true | Search box (accessor columns only) |
| enableColumnVisibility | true | Columns menu + per-column Hide |
| enableRowSelection | true | Master for the whole selection group |
| enableColumnResizing | false | Live drag handles |
| enableColumnPinning | true | Master for pinning |
| enableHeaderCheckbox | true | false greys select-all only |
| fixedCheckboxColumn | true | Locks the select column's pin |
| selectionMode | "multiple" | or "single" |
| striped / hoverable / compact / bordered | true | Presentation |
| fullHeight | false | Card flexes to a bounded parent height (pair with maxHeight) |
| toolbarPosition | 'default' | 'above-header' / 'below-header' |
| groupedFilterLabel | "Global" | Field picker "all" label |
Complete props reference
Every prop DataTable accepts — this is the whole surface, nothing else exists:
| Prop | Type | Default | What it does |
|---|---|---|---|
| data | T[] | required | Row data, rendered client-side |
| columns | GroupColumnDef<T>[] | required | Column defs (Columns) |
| filters | FilterConfig<T>[] | — | Toolbar filter controls (Filters) |
| pagination | PaginationConfig | — | Presence enables paging (Pagination) |
| settings | TableSettings | {} | Feature/presentation toggles (table above) |
| header | node or { content, align, className, showOnEmpty } | — | Sticky title band inside the card; showOnEmpty (default true) keeps it on empty data |
| footer | ReactNode | — | Sticky full-width bottom band |
| className | string | — | Outer wrapper classes |
| maxHeight | string | "100%" | Scroll container cap — what makes header/footer sticky |
| loading | boolean | false | Spinner overlay over the card |
| error | string | — | Replaces the table with an error card (+ Try Again when onRefresh given) |
| emptyMessage | string | "No data available" | Empty-state text |
| onRowSelectionChange | (rows: T[]) => void | — | Selected originals on every change (Selection) |
| onExport | (data, view) => void | — | Enables the Settings-menu Export item (Export) |
| onRefresh | () => void | — | Toolbar Refresh button + error-card Try Again |
| onTableReady | (table) => void | — | TanStack instance escape hatch; fires once per instance |
| children | (table) => ReactNode | — | Render-prop (action bar etc.) |
| isRowSelectable | (row: T) => boolean | — | Row-level selection gate |
| getRowId | (row, index) => string | — | Stable row identity (Selection) |
| defaultSelectedRows | (row: T) => boolean | — | Uncontrolled selection prefill, seeded once |
| onExternalSearch | (values) => void | — | Server-filter commit callback (External filters) |
| aggregations | AggregationConfigMap<T> | {} | Footer computations (Aggregations) |
| onAggregationChange | (columnId, config) => void | — | Enables the toolbar Aggregations menu |
| rowClassName | (row: Row<T>) => string | — | Per-row classes (status colouring) |
| persist | string \| PersistConfig | — | State persistence (Persist) |
FilterConfig fields (per filter):
| Field | Required | Notes |
|---|---|---|
| placeholder | yes | Label everywhere: control, chip, field picker |
| key | one of | Data dot-path (path filter) |
| id + filterFn(row, value) | one of | Custom predicate filter |
| type | no ("text") | text / select / date / daterange / number |
| options | with select | { label, value }[] |
| placement | no ("bar") | "menu" = behind the field picker + chip |
| externalSearch | no (false) | Server-side |
| width, icon, dateFormat | no | Cosmetic |
| shared | no | Group-wide value slot (needs persist.group); typed against DataTableProps's 2nd type param, see Persist |
| persist | no (true) | false = exclude from persistence (mem, localStorage, AND the URL) |
GroupColumnDef extras beyond TanStack: headerAlign, columns, enablePinning, enableColumnVisibility, enableHeaderMenu, reserveMenuSpace, customFooter, exportValue, exportHeader (Columns). PersistConfig: key (required), group, local, pagination (Persist). AggregationConfig: enabled, functions, format, accessor (Aggregations). PaginationConfig: pageSize, pageSizeOptions, showInfo, showPageNumbers (Pagination).
Action bar: DataTableActionBar takes table, visible (default: auto — shown while anything selected), container (portal target, default document.body) plus motion.div props; DataTableActionBarAction takes Button props plus tooltip and isPending; DataTableActionBarSelection takes table.
Troubleshooting
- Filter not rendering →
settings.showToolbar: truemissing (dev warning fires). - Global search can't find a value that's visibly on screen → that column has no accessor; add
accessorFn. - A
keyfilter does nothing → the path doesn't match the data shape (it's a data path, not a column id). - Wrong rows selected after a refetch → pass
getRowId. - Filter cleared from outside (
useTableFilterParam, manualsetSearchParams) comes right back → you deleted the param instead of setting it to"". Absent = restore-from-mem, present-but-empty = clear.useTableFilterParam's setter already does this correctly. sharedautocompletes every group's slots, not just this table's →filtersis a separately declaredconst; add the group asDataTableProps's 2nd type param:DataTableProps<Row, "QC">["filters"]. See Persist.shareddoes nothing → nopersist.groupon the table (warns).- Two tables fight over saved state → same
persist.key(warns). - Field picker missing → needs
enableGlobalFilteron AND at least oneplacement: 'menu'filter. - Two API calls on pages with persisted external filters → see the double API call.
- Aggregations menu missing → needs
onAggregationChange,showToolbar, and a numeric (ormeta.aggregatable) column.
DataTemplate
DataTable's sibling for card/grid layouts: same toolbar/search/filter/pagination shell, but YOU render each item. fields declares which keys are sortable/searchable; filters is a simpler flat-key config. Defaults that differ from DataTable: showToolbar is true, selection is OFF (enableSelection: false). Grid shape via settings.gridCols ({ default: 3, sm: 1, md: 2, lg: 3, xl: 4, "2xl": 6 }) and gap.
<DataTemplate
data={products}
template={(item, index, isSelected, toggleSelect) => (
<ProductCard item={item} selected={isSelected} onClick={toggleSelect} />
)}
fields={[{ key: "Name", label: "Name", searchable: true, sortable: true }]}
pagination={{}}
/>onTemplateReady / children receive a controller (getSelectedItems, selectAll, clearSelection, getFilteredData, getTotalCount, getVisibleCount, refresh, exportData), and it has its own DataTemplateActionBar family.
Full surface — props: data, template, fields ({ key, label, type?, sortable?, filterable?, searchable? }), filters ({ key, label, type, options?, placeholder?, icon? }), pagination, settings, header, footer, className, loading, error, emptyMessage, emptyIcon, onSelectionChange, onExport(data), onRefresh, onTemplateReady, children. Settings: enableSorting, enableFiltering, enableGlobalSearch, enableSelection (default OFF), enableExport, showToolbar (default ON), selectionMode, gridCols, gap, aspectRatio, minCardWidth, maxCardWidth.
Layout system
Zero context, zero factories — plain composable components. The standard page:
<AppContainer>
<AppHeader title="Quality Check" description="Pending takas" backAction={{ href: "/manufacturing" }}>
<Button>New Entry</Button> {/* lands right-aligned automatically */}
</AppHeader>
<AppContent> {/* fills remaining height, body scrolls */}
<AppContentHeader>…pinned band above the scroll…</AppContentHeader>
<DataTable ... />
<AppContentFooter>…pinned band below…</AppContentFooter>
<AppSheet open={open} onOpenChange={setOpen}>…side panel…</AppSheet>
</AppContent>
<AppFooter>
<span>3 selected</span> {/* leading children sit left */}
<div className="flex items-center gap-4"> {/* LAST child → pushed right */}
<Button variant="secondary">Cancel</Button>
<Button>Save</Button>
</div>
</AppFooter>
</AppContainer>AppContainer
Full-height flex column, gap-2. Props: children, className.
AppHeader
Props: title, description, backAction: { href?, onClick? } (back-arrow button; href is a typed route once the router registry is augmented), metadata: [{ label, value }] (small label/value pairs under the title; falsy values skipped), children, childrenClassName, className.
The title block takes only the space it needs. children render inside a baked flex flex-1 items-center justify-end gap-4 h-full row — content lands right-aligned and vertically centered with no wrapper. Different layout: childrenClassName="justify-between" etc. (twMerge — yours wins), or a <div className="flex-1" /> spacer / mr-auto on a child.
Fragment gotcha (header AND footer): a fragment <>A B</> as a direct child melts into multiple DOM children, and positional CSS (gap, last-child) treats them separately. Wrap grouped elements in a div.
AppContent
The page's content slot: fills the remaining space of its flex parent (flex-auto min-h-0), rounded bg-background card, children scroll inside. Props: className (root — p-0, border shadow-sm, flex-none for a content-sized block), contentClassName (inner scroll wrapper — has p-2; pass p-0 to drop it, flex to make the body a flex row).
Three marker components work only as DIRECT children — AppContent extracts them and renders the real thing; they render nothing on their own:
AppContentHeader/AppContentFooter— pinned bands outside the scroll region (border-b/border-t, ownp-2,classNameto adjust). For toolbars/summaries that must not scroll away.AppSheet— the side panel (one per AppContent, controlled only):
<AppSheet open={open} onOpenChange={setOpen} side="right" className="w-[600px]">
<PartyDetail … />
</AppSheet>| Prop | Default | Notes |
|---|---|---|
| open / onOpenChange | required | Controlled only |
| side | "right" | or "left" |
| overlay | true | Slides over the dimmed body; click outside closes. overlay={false} = push mode: the sheet takes its width and the body shrinks, animated both ways. Below 1024px push auto-falls back to overlay — a fixed panel would cramp the main content |
| className | w-[420px] max-w-full | Merged (twMerge): override width (w-1/3, w-[600px]) or add styling (p-0). Sheets want a FIXED comfortable width — the MAIN content is the responsive part |
AppFooter
Flex row with p-2, gap-4, and the last child pushed to the right edge ([&>*:last-child]:ml-auto) — actions go last, anything before them sits left. Renders nothing without children. Override via className.
Utilities & core components
GeneralHelper — static, no setup:
GeneralHelper.formatDate(d) // "07-07-2026" (dd-mm-yyyy default)
GeneralHelper.formatDate(d, { format: "dd-mmm-yyyy" }) // "07-Jul-2026"; also "iso" | "long" | "short", separator option
GeneralHelper.formatINR(125000) // "₹ 1,25,000.00"
GeneralHelper.formatINR(125000, { compact: true }) // "₹ 1.25L" style for >= 1 lakh
GeneralHelper.toProperCase("mill process") // "Mill Process" (toProperCaseAdvanced handles separators)
GeneralHelper.copyToClipboard(text) // promise; <GeneralHelper.CopyToClipboard text=... /> for the button
<GeneralHelper.StatusBadge status="Completed" />Loader, ErrorComponent, NotFound, Unauthorized, OfflineUI — full-area state screens. CurrencyTransfer — transfer/success display.
For maintainers
- Behavior reference = the JSDoc in
data-table-types.ts; keep it and this README in sync when the API moves. - Monorepo dev loop: after lib edits
npm run build, copydist/into the consuming app'snode_modules/@akshar-technosoft/ui/dist/, restart the TS server. - The table renders with
border-separate(sticky cells drift ~1px underborder-collapse). Consequence: borders must live on CELLS — aborder-bon<tr>never paints in this mode. - Radix dropdown/menu contents are portaled to
body, but React synthetic events still bubble through the COMPONENT tree — any click-handling wrapper around a menu needsstopPropagationon the menu content (the header's click-to-sort learned this the hard way).
