npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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/ui

Colors 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 your cell's business).
  • columns: [...] — nested columns under a grouped header row.
  • enablePinning: false — remove this column from the pin menu.
  • enableColumnVisibility: false — per-column override for settings.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 native enableHiding — 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 interactive header (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; content may 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 with enableSorting: true.
  • A column's own enableSorting: false turns sorting off for just that column, table-wide sorting stays on for the rest. This is the normal way to make specific columns unsortable — leave settings.enableSorting alone (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 accessorKey exists → 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 params

Why 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: false greys it while per-row stays usable). "single" — radio, whole row clickable, previous selection cleared; showCheckbox: false hides the radio but keeps the row clickable.
  • isRowSelectable={(row) => boolean} — failing rows grey out, select-all skips them, onRowSelectionChange never 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 React key.

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 pair

Bulk 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-column enableColumnVisibility: false (or TanStack's native enableHiding: 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: false on a column suppresses the unconditionally, even if sort/pin/hide are on; the sort chevron and click-to-sort are unaffected (that's enableSorting'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 local needed.
  • Share/bookmark a URL with ?LotNo=X — deep-links straight into a pre-filtered table.
  • local: true additionally 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 literally view.originals — kept as the first arg so simple handlers can ignore view.
  • 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 JSX

Toolbar & 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 renderingsettings.showToolbar: true missing (dev warning fires).
  • Global search can't find a value that's visibly on screen → that column has no accessor; add accessorFn.
  • A key filter 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, manual setSearchParams) 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.
  • shared autocompletes every group's slots, not just this table'sfilters is a separately declared const; add the group as DataTableProps's 2nd type param: DataTableProps<Row, "QC">["filters"]. See Persist.
  • shared does nothing → no persist.group on the table (warns).
  • Two tables fight over saved state → same persist.key (warns).
  • Field picker missing → needs enableGlobalFilter on AND at least one placement: '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 (or meta.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, own p-2, className to 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, copy dist/ into the consuming app's node_modules/@akshar-technosoft/ui/dist/, restart the TS server.
  • The table renders with border-separate (sticky cells drift ~1px under border-collapse). Consequence: borders must live on CELLS — a border-b on <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 needs stopPropagation on the menu content (the header's click-to-sort learned this the hard way).