@nexgrid/react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,432 @@
1
+ # @nexgrid/react
2
+
3
+ A server-driven data grid for React and Next.js.
4
+
5
+ NexGrid renders one page of rows at a time and never holds the dataset. Every
6
+ piece of user intent — page, page size, sort, search, filters — is expressed as
7
+ a single `QueryState` object that **you** own; the grid hands you the next one
8
+ and re-renders when you hand back the matching page. That is the whole contract.
9
+ There is no local sort that quietly reorders 10 rows out of 40,000, and no
10
+ client-side filter that hides records the total still counts.
11
+
12
+ Around that core it provides the things every real admin table ends up needing:
13
+ debounced global search, a sort cycle, column visibility, row density,
14
+ selection, formatted Excel and CSV export, a paginated footer with a page-jump,
15
+ loading / empty / error states, and a card layout for phones — all styled by one
16
+ stylesheet shared with the Angular and vanilla adapters, so the same grid looks
17
+ identical on every platform.
18
+
19
+ - Zero runtime dependencies beyond `@nexgrid/core`. React is a peer dependency.
20
+ - Written for strict TypeScript, generic over your row type.
21
+ - Ships ESM and CJS, with a `"use client"` banner so it drops straight into the
22
+ Next.js App Router.
23
+
24
+ ## Installation
25
+
26
+ ```bash
27
+ npm install @nexgrid/react @nexgrid/core
28
+ ```
29
+
30
+ Import the stylesheet once, anywhere in your app:
31
+
32
+ ```ts
33
+ import "@nexgrid/react/styles.css";
34
+ ```
35
+
36
+ ## Quick start
37
+
38
+ The grid is fully controlled. Hold a `QueryState` in state, fetch whenever it
39
+ changes, and pass the result straight through.
40
+
41
+ ```tsx
42
+ "use client";
43
+
44
+ import { useCallback, useEffect, useState } from "react";
45
+ import {
46
+ NexGrid,
47
+ defaultQuery,
48
+ serializeQuery,
49
+ type NexGridReactColumn,
50
+ type PagedResponse,
51
+ type QueryState,
52
+ } from "@nexgrid/react";
53
+ import "@nexgrid/react/styles.css";
54
+
55
+ interface Student {
56
+ id: number;
57
+ name: string;
58
+ email: string;
59
+ status: "Active" | "Pending" | "Disabled";
60
+ joinedAt: string;
61
+ }
62
+
63
+ const columns: NexGridReactColumn<Student>[] = [
64
+ { accessorKey: "name", header: "Name", meta: { minWidth: 180 } },
65
+ { accessorKey: "email", header: "Email" },
66
+ {
67
+ accessorKey: "status",
68
+ header: "Status",
69
+ meta: { align: "center", width: 130 },
70
+ cell: ({ getValue }) => {
71
+ const status = String(getValue());
72
+ return <span className={`pill pill--${status.toLowerCase()}`}>{status}</span>;
73
+ },
74
+ },
75
+ {
76
+ accessorKey: "joinedAt",
77
+ header: "Joined",
78
+ cell: ({ getValue }) => new Date(String(getValue())).toLocaleDateString(),
79
+ },
80
+ ];
81
+
82
+ export function StudentsGrid() {
83
+ const [query, setQuery] = useState<QueryState>(defaultQuery());
84
+ const [page, setPage] = useState<PagedResponse<Student> | null>(null);
85
+ const [isLoading, setIsLoading] = useState(true);
86
+ const [error, setError] = useState(false);
87
+
88
+ const load = useCallback(async (next: QueryState) => {
89
+ setIsLoading(true);
90
+ setError(false);
91
+ try {
92
+ // serializeQuery produces ?page=2&pageSize=25&sort=name:asc&q=smith
93
+ const response = await fetch(`/api/students?${serializeQuery(next)}`);
94
+ if (!response.ok) throw new Error(`HTTP ${response.status}`);
95
+ setPage((await response.json()) as PagedResponse<Student>);
96
+ } catch {
97
+ setError(true);
98
+ } finally {
99
+ setIsLoading(false);
100
+ }
101
+ }, []);
102
+
103
+ useEffect(() => {
104
+ void load(query);
105
+ }, [load, query]);
106
+
107
+ return (
108
+ <NexGrid
109
+ caption="Students"
110
+ columns={columns}
111
+ data={page?.items ?? []}
112
+ total={page?.total ?? 0}
113
+ query={query}
114
+ onQueryChange={setQuery}
115
+ isLoading={isLoading}
116
+ error={error}
117
+ onRetry={() => void load(query)}
118
+ enableSelection
119
+ onSelectionChange={(ids) => console.log("selected", ids)}
120
+ // Lets an export page in the whole filtered dataset, not just this page.
121
+ fetchEndpoint="/api/students"
122
+ // The grid never renders toasts — forward these to your own.
123
+ onNotify={({ type, message }) => console.info(type, message)}
124
+ />
125
+ );
126
+ }
127
+ ```
128
+
129
+ Your endpoint must answer with a `PagedResponse<T>`:
130
+
131
+ ```json
132
+ { "items": [], "page": 1, "pageSize": 10, "total": 0, "totalPages": 1 }
133
+ ```
134
+
135
+ If your API is ASP.NET Core, `NexGrid.AspNetCore` binds exactly the query string
136
+ `serializeQuery` produces and returns exactly this shape.
137
+
138
+ ### Putting the query in the URL
139
+
140
+ `QueryState` round-trips through a query string, so making the grid shareable and
141
+ back-button-friendly is a swap of the state hook:
142
+
143
+ ```tsx
144
+ const searchParams = useSearchParams();
145
+ const router = useRouter();
146
+ const query = useMemo(() => parseQuery(searchParams.toString()), [searchParams]);
147
+
148
+ <NexGrid
149
+ query={query}
150
+ onQueryChange={(next) => router.replace(`?${serializeQuery(next)}`)}
151
+ {...rest}
152
+ />;
153
+ ```
154
+
155
+ `parseQuery` degrades safely — a bad page becomes 1, an unknown page size
156
+ becomes the default, malformed sort tokens are dropped — so a hand-edited URL can
157
+ never put the grid into an impossible state.
158
+
159
+ ## Next.js App Router
160
+
161
+ The published bundle starts with `"use client"`, so `<NexGrid />` can be imported
162
+ directly from a Server Component without a wrapper:
163
+
164
+ ```tsx
165
+ // app/students/page.tsx — a Server Component
166
+ import { StudentsGrid } from "./students-grid";
167
+
168
+ export default async function Page() {
169
+ return <StudentsGrid />;
170
+ }
171
+ ```
172
+
173
+ Two notes:
174
+
175
+ - Anything you pass through props still crosses the server/client boundary, so
176
+ `columns` (which contains `cell` functions) must be defined in a file marked
177
+ `"use client"`, not in the server page.
178
+ - Import `@nexgrid/react/styles.css` from your root layout, or from the client
179
+ component itself if your setup supports component-level CSS imports.
180
+
181
+ ## Props
182
+
183
+ | Prop | Type | Default | Description |
184
+ |------|------|---------|-------------|
185
+ | `columns` | `NexGridReactColumn<TData>[]` | required | Column definitions, in display order. |
186
+ | `data` | `TData[]` | required | The **current page** of rows only. |
187
+ | `total` | `number` | required | Total filtered row count from the server. Drives the pager. |
188
+ | `query` | `QueryState` | required | The query the `data` above answers. |
189
+ | `onQueryChange` | `(next: QueryState) => void` | required | Called with the next query on page / size / sort / search changes. |
190
+ | `caption` | `string` | required | Accessible name for the table; also the default export file name and sheet title. |
191
+ | `density` | `"compact" \| "default" \| "comfortable"` | `"default"` | **Initial** density. The user owns it afterwards. |
192
+ | `isLoading` | `boolean` | `false` | Replaces the rows with a spinner. The toolbar and footer stay usable. |
193
+ | `error` | `boolean` | `false` | Replaces the **whole grid** with an error card. |
194
+ | `onRetry` | `() => void` | — | When set, the error card offers a retry button. |
195
+ | `enableSelection` | `boolean` | `false` | Renders selection checkboxes. |
196
+ | `onSelectionChange` | `(ids: string[], allAcrossSelected: boolean) => void` | — | Fires after each selection change. `allAcrossSelected` is reserved and always `false`. |
197
+ | `enableSearch` | `boolean` | `true` | Shows the debounced global search box. |
198
+ | `searchPlaceholder` | `string` | `locale.searchPlaceholder` | Placeholder text for the search box. |
199
+ | `toolbarActions` | `ReactNode` | — | Rendered at the end of the toolbar, after the export menu. |
200
+ | `onRowClick` | `(row: TData) => void` | — | Row / card click handler. Adds a pointer cursor and makes rows keyboard-activatable. |
201
+ | `getRowId` | `(row: TData) => string` | `String(row.id ?? row)` | Stable row identity, used for selection and React keys. |
202
+ | `className` | `string` | — | Extra class(es) on the grid root. |
203
+ | `showSerialNumber` | `boolean` | `true` | Shows the automatic `S.No.` column, numbered across the whole result set. |
204
+ | `enableExport` | `boolean` | `true` | Shows the export menu. |
205
+ | `exportFileName` | `string` | caption, lower-cased and underscored | File name prefix, without extension. |
206
+ | `onExportAll` | `() => void \| Promise<void>` | — | Takes over exporting entirely; the built-in flow never runs. |
207
+ | `fetchEndpoint` | `string` | — | List endpoint used to page in the rest of the dataset when exporting. |
208
+ | `badgeRules` | `readonly ExcelBadgeRule[]` | core's `DEFAULT_BADGE_RULES` | Value-based cell styling for the Excel export. |
209
+ | `locale` | `Partial<NexGridLocale>` | English defaults | Overrides for any user-facing string. |
210
+ | `onNotify` | `(notice: NexGridNotice) => void` | no-op | Receives `{ type, message }` for export progress, failures, and successes. |
211
+ | `theme` | `"light" \| "dark" \| "auto"` | `"light"` | Adds `.nxg-dark` / `.nxg-auto` to the root. |
212
+
213
+ ## Column definitions
214
+
215
+ A column is a plain object, structurally compatible with TanStack Table's
216
+ `ColumnDef` — existing column sets usually work unchanged.
217
+
218
+ | Field | Type | Description |
219
+ |-------|------|-------------|
220
+ | `id` | `string` | Column id. Falls back to `accessorKey`. |
221
+ | `accessorKey` | `string` | The row property this column reads. |
222
+ | `header` | `string \| (ctx) => ReactNode` | Header content. A string is also used for menus and export headers. |
223
+ | `cell` | `(ctx: { row: { original: TData }, getValue(): unknown }) => ReactNode` | Custom cell renderer. Without it the raw value is rendered as text. |
224
+ | `enableSorting` | `boolean` | Sorting is on by default; set `false` to opt out. |
225
+ | `meta` | `NexGridColumnMeta` | Layout and behavior hints — see below. |
226
+
227
+ ### `meta`
228
+
229
+ | Field | Type | Description |
230
+ |-------|------|-------------|
231
+ | `width` | `number` | Fixed pixel width. |
232
+ | `minWidth` | `number` | Minimum width in pixels. Defaults to `120` when no `width` is set. |
233
+ | `align` | `"left" \| "center" \| "right"` | Header and cell alignment. |
234
+ | `hidden` | `boolean` | Start hidden. Still listed in the Columns menu. |
235
+ | `hideable` | `boolean` | Set `false` to keep the column out of the Columns menu. |
236
+ | `exportable` | `boolean` | Set `false` to keep the column out of CSV/Excel exports. |
237
+ | `serverFilterable`, `serverFilterField`, `filterOptions` | — | Declare a column as server-filterable (`filter[field]=value`). |
238
+
239
+ Two ids are structural: `select` and `actions`. They are never sortable, never
240
+ hideable, and never exported.
241
+
242
+ ### Custom cells
243
+
244
+ `cell` returns any `ReactNode`, and the same renderer is used by the table and
245
+ the mobile card list, so the two can never drift apart.
246
+
247
+ ```tsx
248
+ const columns: NexGridReactColumn<Student>[] = [
249
+ // A status pill.
250
+ {
251
+ accessorKey: "status",
252
+ header: "Status",
253
+ meta: { align: "center", width: 130 },
254
+ cell: ({ getValue }) => {
255
+ const status = String(getValue());
256
+ return <span className={`pill pill--${status.toLowerCase()}`}>{status}</span>;
257
+ },
258
+ },
259
+
260
+ // Composed from more than one field — reach through `row.original`.
261
+ {
262
+ id: "student",
263
+ header: "Student",
264
+ cell: ({ row }) => (
265
+ <div className="stack">
266
+ <strong>{row.original.name}</strong>
267
+ <small>{row.original.email}</small>
268
+ </div>
269
+ ),
270
+ },
271
+
272
+ // A row action menu. `actions` is structural: unsortable and never exported.
273
+ {
274
+ id: "actions",
275
+ header: "",
276
+ meta: { align: "right", width: 64 },
277
+ cell: ({ row }) => (
278
+ <button type="button" onClick={(event) => event.stopPropagation()}>
279
+ Edit
280
+ </button>
281
+ ),
282
+ },
283
+ ];
284
+ ```
285
+
286
+ Two things worth knowing:
287
+
288
+ - Exports read the **underlying row value**, not the rendered cell. A custom cell
289
+ is presentation; the `status` column above exports `Active`, not the markup of
290
+ the pill. Set `meta.exportable: false` on columns that carry no data.
291
+ - When `onRowClick` is set, call `event.stopPropagation()` in interactive cell
292
+ content so a button click does not also open the row. Selection checkboxes
293
+ already do this for you.
294
+
295
+ ## Theming
296
+
297
+ Every color and shape in the stylesheet reads a CSS custom property, so you
298
+ re-skin the grid by overriding tokens — no class overrides, no `!important`.
299
+
300
+ ```css
301
+ .nxg-root {
302
+ --nxg-primary: #7c3aed;
303
+ --nxg-primary-fg: #ffffff;
304
+ --nxg-radius: 8px;
305
+ --nxg-font: "Inter", system-ui, sans-serif;
306
+ }
307
+ ```
308
+
309
+ | Token | Purpose |
310
+ |-------|---------|
311
+ | `--nxg-font`, `--nxg-font-mono` | Body font, and the serial-number font. |
312
+ | `--nxg-bg` | Input and pager background. |
313
+ | `--nxg-card`, `--nxg-card-2` | Panel background, and the table header band. |
314
+ | `--nxg-border` | Every border and divider. |
315
+ | `--nxg-fg`, `--nxg-muted-fg` | Primary and secondary text. |
316
+ | `--nxg-muted` | Hover fills and subtle chips. |
317
+ | `--nxg-primary`, `--nxg-primary-fg` | Accent: sort icons, current page, selection. |
318
+ | `--nxg-danger` | Destructive accents. |
319
+ | `--nxg-radius`, `--nxg-radius-sm` | Panel and control corner radii. |
320
+ | `--nxg-shadow`, `--nxg-focus-ring` | Elevation, and the focus ring. |
321
+
322
+ Dark mode is a class, not a media query, so it can follow whatever your app
323
+ already uses:
324
+
325
+ ```tsx
326
+ <NexGrid theme="dark" {...props} /> {/* always dark */}
327
+ <NexGrid theme="auto" {...props} /> {/* follows the OS */}
328
+ ```
329
+
330
+ `theme="dark"` puts `.nxg-dark` on the grid root. If your app already toggles a
331
+ dark class higher up the tree, add `nxg-dark` alongside it and leave `theme`
332
+ alone — the stylesheet matches `.nxg-dark .nxg-root` as well.
333
+
334
+ Responsive behavior is driven entirely by the stylesheet: the grid renders both a
335
+ table and a card list, and CSS shows the table at ≥ 768px and the cards below it.
336
+
337
+ ## Exporting
338
+
339
+ The export menu offers a formatted Excel workbook (`.xls`, with colored status
340
+ badges) and a raw CSV (RFC 4180, UTF-8 BOM, with spreadsheet-formula injection
341
+ neutralized). Both write the **visible** columns, minus anything marked
342
+ `meta.exportable: false`.
343
+
344
+ By default an export contains the current page. Pass `fetchEndpoint` and the grid
345
+ will page through the rest of the filtered dataset first, preserving the active
346
+ search, sort, and filters, at 100 rows per request up to a 2,000-row safety cap.
347
+ If those requests fail it notifies you and falls back to the current page rather
348
+ than producing nothing.
349
+
350
+ ```tsx
351
+ <NexGrid
352
+ fetchEndpoint="/api/students"
353
+ exportFileName="student_roster"
354
+ badgeRules={[
355
+ { values: ["Active"], background: "#dcfce7", color: "#15803d" },
356
+ { values: ["Disabled"], background: "#fee2e2", color: "#b91c1c" },
357
+ ]}
358
+ onNotify={({ type, message }) => toast[type](message)}
359
+ {...props}
360
+ />
361
+ ```
362
+
363
+ To export server-side instead — a real `.xlsx`, a background job, a signed
364
+ download URL — pass `onExportAll`. It replaces the built-in flow completely.
365
+
366
+ ## Notifications
367
+
368
+ The grid never renders toasts. A toast belongs to your design system, and two
369
+ competing toast stacks in one page is a worse bug than no toast at all. Anything
370
+ the grid wants to say arrives at `onNotify` as `{ type, message }` where `type`
371
+ is `"info" | "success" | "error"`, ready to forward to whatever you already use.
372
+
373
+ ## Localization
374
+
375
+ Every user-facing string comes from a locale object. Override any subset:
376
+
377
+ ```tsx
378
+ <NexGrid
379
+ locale={{
380
+ searchPlaceholder: "Rechercher…",
381
+ emptyText: "Aucun enregistrement ne correspond à votre recherche.",
382
+ showingRange: "Affichage de {start} à {end} sur {total} entrées",
383
+ rowsPerPage: "Lignes :",
384
+ }}
385
+ {...props}
386
+ />
387
+ ```
388
+
389
+ Templates keep their `{placeholder}` tokens so word order stays yours. Import
390
+ `DEFAULT_LOCALE` from this package to see every key.
391
+
392
+ ## Accessibility
393
+
394
+ - `aria-label={caption}` on the table; every icon-only control has an accessible
395
+ name drawn from the locale.
396
+ - Sortable headers are focusable, activate on Enter or Space, and carry
397
+ `aria-sort` (`ascending` / `descending` / `none`).
398
+ - Menus are `role="menu"` with `role="menuitemcheckbox"` and `aria-checked`
399
+ items; they close on outside click and on Escape, which returns focus to the
400
+ trigger.
401
+ - The header checkbox reports the mixed state when only part of a page is
402
+ selected.
403
+ - The current page button carries `aria-current="page"`.
404
+ - When `onRowClick` is set, rows and cards become focusable and activate on
405
+ Enter. Keep genuinely interactive content in a cell rather than relying on the
406
+ row handler alone.
407
+
408
+ ## Re-exported from `@nexgrid/core`
409
+
410
+ For convenience, the pieces a host needs to drive a controlled grid are
411
+ re-exported from this package, so most apps never import `@nexgrid/core`
412
+ directly:
413
+
414
+ `defaultQuery`, `parseQuery`, `serializeQuery`, `buildQueryUrl`, `primarySort`,
415
+ `withToggledSort`, `withSort`, `withSearch`, `withPage`, `withPageSize`,
416
+ `withFilter`, `totalPagesFor`, `isPageSize`, `PAGE_SIZES`, `DEFAULT_PAGE_SIZE`,
417
+ `DEFAULT_LOCALE`, `resolveLocale`.
418
+
419
+ Always mutate a `QueryState` through those reducers rather than spreading it by
420
+ hand — they are what guarantee that a search or a page-size change resets to page
421
+ one and that the sort cycle stays `asc → desc → cleared` across every adapter.
422
+
423
+ ## Author & Maintainer
424
+
425
+ **Chhagan Sinha**
426
+ - 📧 Contact: [sinhachhagan@outlook.com](mailto:sinhachhagan@outlook.com)
427
+ - 🐙 GitHub: [@ChhaganSinha](https://github.com/ChhaganSinha)
428
+
429
+ ## License
430
+
431
+ [MIT](https://github.com/ChhaganSinha/NexGrid/blob/main/LICENSE) © 2026 Chhagan Sinha
432
+