@dynostack/react-grid 0.3.2 → 0.4.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 CHANGED
@@ -13,12 +13,45 @@ Built on [TanStack Table v8](https://tanstack.com/table) · [Radix UI](https://w
13
13
 
14
14
  </div>
15
15
 
16
+ **[Live showcase](https://dynostack-react-grid.vercel.app)** · **[Interactive playground](https://dynostack-react-grid.vercel.app/playground)** · **[Setup and configuration docs](https://dynostack-react-grid.vercel.app/docs)**
17
+
18
+ Try every feature on real sample data, customize themes and density, share a configuration link, and copy the matching React props. The demo includes spring-based interactions and works on mobile.
19
+
16
20
  ---
17
21
 
18
22
  A single `<DataTable />` component that gives you ag-grid–level functionality with a fraction of the API surface and a shadcn/ui aesthetic. Every behavior is opt-in via props — drop it in and it works; configure it and it scales.
19
23
 
24
+ ## Showcase
25
+
26
+ <p align="center">
27
+ <img src="public/table-images/Screenshot%202026-05-25%20233937.png" alt="React data grid with toolbar, sorting, filters, pagination, export, and add-row controls" width="100%" />
28
+ </p>
29
+
30
+ | Selection + bulk actions | Column menu |
31
+ | --- | --- |
32
+ | <img src="public/table-images/Screenshot%202026-05-25%20234013.png" alt="Rows selected with bulk delete and clear actions visible in the toolbar" width="100%" /> | <img src="public/table-images/Screenshot%202026-05-25%20234035.png" alt="Column menu with ascending sort, descending sort, pin, unpin, and hide column actions" width="100%" /> |
33
+
34
+ | Filter builder | Column visibility |
35
+ | --- | --- |
36
+ | <img src="public/table-images/Screenshot%202026-05-25%20234221.png" alt="Column filter builder with operator select, value input, add condition, clear, close, and apply controls" width="100%" /> | <img src="public/table-images/Screenshot%202026-05-25%20234246.png" alt="Column visibility popover with checked columns and reset control" width="100%" /> |
37
+
38
+ | Export selected rows | Inline add and edit |
39
+ | --- | --- |
40
+ | <img src="public/table-images/Screenshot%202026-05-25%20234301.png" alt="Export menu for selected rows with CSV and Excel options" width="100%" /> | <img src="public/table-images/Screenshot%202026-05-25%20234313.png" alt="Inline add-row editing with text fields, select input, date input, save, and cancel controls" width="100%" /> |
41
+
42
+ | Row actions | Details panel |
43
+ | --- | --- |
44
+ | <img src="public/table-images/Screenshot%202026-05-25%20234410.png" alt="Row actions menu with view, edit, duplicate, and delete commands" width="100%" /> | <img src="public/table-images/Screenshot%202026-05-25%20234428.png" alt="Right-side details panel with compact, relaxed, and comfy density controls" width="100%" /> |
45
+
46
+ | Search |
47
+ | --- |
48
+ | <img src="public/table-images/Screenshot%202026-05-25%20234453.png" alt="Search input expanded in the table toolbar" width="100%" /> |
49
+
20
50
  ## Highlights
21
51
 
52
+ - **Layout and state controls** — `initialSorting`, `onSelectionChange`, `ariaLabel`, `striped`, `stickyHeader`, and `maxHeight`. Disabling pagination renders all matching loaded rows.
53
+ - **Safer exports and editing** — spreadsheet formulas in untrusted text are neutralized; row editing respects `meta.isEditable`; theme values cannot escape scoped CSS declarations.
54
+
22
55
  - **Filters that actually filter** — text, number, date with operators (`contains`, `not contains`, `equals`, `before`, `after`, `in range`, `blank`, `not blank`, …), AND/OR combine of two conditions, and a set filter with search + select-all
23
56
  - **Inline editing** — double-click cell to edit, or enter row-edit mode with `Save` / `Cancel`
24
57
  - **Add row** — local optimistic insert, edit, then commit on save
@@ -39,6 +72,7 @@ A single `<DataTable />` component that gives you ag-grid–level functionality
39
72
  ## Table of contents
40
73
 
41
74
  - [Install](#install)
75
+ - [Showcase](#showcase)
42
76
  - [Tailwind setup](#tailwind-setup)
43
77
  - [Theme tokens](#theme-tokens)
44
78
  - [Quick start](#quick-start)
@@ -67,6 +101,34 @@ A single `<DataTable />` component that gives you ag-grid–level functionality
67
101
 
68
102
  ## Install
69
103
 
104
+ ### Run the showcase locally
105
+
106
+ ```sh
107
+ git clone https://github.com/wanted-coder-vijay/wcv-data-grid.git
108
+ cd wcv-data-grid
109
+ npm install
110
+ npm run build
111
+ npm install --prefix showcase
112
+ npm run showcase:dev
113
+ ```
114
+
115
+ ### New layout controls
116
+
117
+ ```tsx
118
+ <DataTable
119
+ data={rows}
120
+ columns={columns}
121
+ initialSorting={[{ id: "name", desc: false }]}
122
+ onSelectionChange={(selectedRows) => setSelectedRows(selectedRows)}
123
+ ariaLabel="Project workspace"
124
+ striped
125
+ stickyHeader
126
+ maxHeight="480px"
127
+ />
128
+ ```
129
+
130
+ `onSelectionChange` returns selected **loaded** row objects; server-side exports and selection do not fetch unseen pages. Editing flags are UI controls: your server must also validate fields, values, and permissions. CSV/Excel exports neutralize formula prefixes in strings while preserving actual numeric values. Excel output remains an HTML-based `.xls`, not native XLSX. Theme tokens reject declaration delimiters, CSS comments, backslash escapes, and URL expressions.
131
+
70
132
  ```sh
71
133
  npm i @dynostack/react-grid
72
134
  # or
@@ -75,7 +137,7 @@ pnpm add @dynostack/react-grid
75
137
  yarn add @dynostack/react-grid
76
138
  ```
77
139
 
78
- **Peer deps:** `react >= 18`, `react-dom >= 18`. All other deps (`@tanstack/react-table`, `radix-ui`, `lucide-react`, `class-variance-authority`, `clsx`, `tailwind-merge`) are bundled.
140
+ **Peer deps:** `react >= 18.2`, `react-dom >= 18.2`. All other runtime dependencies (`@tanstack/react-table`, `radix-ui`, `lucide-react`, `class-variance-authority`, `clsx`, `tailwind-merge`) are installed automatically and remain external to the package bundle.
79
141
 
80
142
  ## Tailwind setup
81
143
 
@@ -850,6 +912,12 @@ operations and only renders the returned page.
850
912
  | `pageSizeOptions` | `number[]` | shadcn defaults | Page-size dropdown options. |
851
913
  | `initialColumnPinning` | `ColumnPinningState` | `{ left: [], right: [] }` | Initial pinned columns. |
852
914
  | `initialColumnVisibility` | `VisibilityState` | `{}` | Initial hidden columns. |
915
+ | `initialSorting` | `SortingState` | `[]` | Initial column sort order. |
916
+ | `onSelectionChange` | `(rows: TData[]) => void` | — | Observe selected loaded rows. |
917
+ | `ariaLabel` | `string` | `"Data table"` | Accessible name for the table. |
918
+ | `striped` | `boolean` | `false` | Alternate row backgrounds. |
919
+ | `stickyHeader` | `boolean` | `false` | Keep headers visible in the scroll viewport. |
920
+ | `maxHeight` | `CSSProperties["maxHeight"]` | — | Limit the vertical scroll viewport. |
853
921
  | `globalFilter` | `string` | uncontrolled | Controlled global filter value. |
854
922
  | `onGlobalFilterChange` | `(value: string) => void` | — | Controlled global filter setter. |
855
923
  | `className` | `string` | — | Extra classes on the table root. |
package/dist/index.cjs CHANGED
@@ -283,15 +283,21 @@ function PopoverContent({
283
283
  }
284
284
 
285
285
  // src/export.ts
286
+ function sanitizeSpreadsheetValue(value) {
287
+ if (value == null) return "";
288
+ const text = String(value);
289
+ if (typeof value === "string" && (/^[\s\u0000-\u001f]*[=+\-@=+-@]/u.test(text) || /^[\t\r\n]/.test(text))) return `'${text}`;
290
+ return text;
291
+ }
286
292
  function escapeCsv(value) {
287
293
  if (value === null || value === void 0) return "";
288
- const s = String(value);
294
+ const s = sanitizeSpreadsheetValue(value);
289
295
  if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
290
296
  return s;
291
297
  }
292
298
  function escapeHtml(value) {
293
299
  if (value === null || value === void 0) return "";
294
- return String(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
300
+ return sanitizeSpreadsheetValue(value).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
295
301
  }
296
302
  function getExportRows(table) {
297
303
  const visible = table.getVisibleLeafColumns().filter((c) => {
@@ -1901,7 +1907,7 @@ function EditableCell({
1901
1907
  if (typeof flag === "function") return flag(row.original);
1902
1908
  return flag;
1903
1909
  })();
1904
- if (!isEditing) {
1910
+ if (!isEditing || !isEditable) {
1905
1911
  const display = renderDisplay(initial, editor, meta);
1906
1912
  return /* @__PURE__ */ jsxRuntime.jsx(
1907
1913
  "div",
@@ -1918,7 +1924,9 @@ function EditableCell({
1918
1924
  }
1919
1925
  );
1920
1926
  }
1921
- const commit = (value) => onCommit(value);
1927
+ const commit = (value) => {
1928
+ if (isEditable) onCommit(value);
1929
+ };
1922
1930
  const handleKey = (e) => {
1923
1931
  if (e.key === "Enter") commit(local);
1924
1932
  else if (e.key === "Escape") onCancel();
@@ -2048,6 +2056,9 @@ var TOKEN_TO_VAR = {
2048
2056
  radius: "--radius",
2049
2057
  fontFamily: "--font-family"
2050
2058
  };
2059
+ function isSafeToken(value) {
2060
+ return typeof value === "string" && !/[;{}<>@\\\u0000-\u001f]/.test(value) && !/\/\*|\*\/|url\s*\(|expression\s*\(/i.test(value);
2061
+ }
2051
2062
  function isModed(theme) {
2052
2063
  if (!theme) return false;
2053
2064
  return "light" in theme || "dark" in theme;
@@ -2056,8 +2067,8 @@ function tokensToStyle(tokens) {
2056
2067
  if (!tokens) return {};
2057
2068
  const out = {};
2058
2069
  for (const [k, v] of Object.entries(tokens)) {
2059
- if (v == null) continue;
2060
- const cssVar = TOKEN_TO_VAR[k];
2070
+ if (!isSafeToken(v)) continue;
2071
+ const cssVar = Object.prototype.hasOwnProperty.call(TOKEN_TO_VAR, k) ? TOKEN_TO_VAR[k] : void 0;
2061
2072
  if (cssVar) out[cssVar] = v;
2062
2073
  }
2063
2074
  return out;
@@ -2131,8 +2142,8 @@ function tokensToCssBlock(tokens) {
2131
2142
  if (!tokens) return "";
2132
2143
  const parts = [];
2133
2144
  for (const [k, v] of Object.entries(tokens)) {
2134
- if (v == null) continue;
2135
- const cssVar = TOKEN_TO_VAR[k];
2145
+ if (!isSafeToken(v)) continue;
2146
+ const cssVar = Object.prototype.hasOwnProperty.call(TOKEN_TO_VAR, k) ? TOKEN_TO_VAR[k] : void 0;
2136
2147
  if (cssVar) parts.push(`${cssVar}:${v}`);
2137
2148
  }
2138
2149
  return parts.join(";");
@@ -2556,6 +2567,10 @@ function useEditingContext() {
2556
2567
  if (!ctx) throw new Error("DataTable editing context missing");
2557
2568
  return ctx;
2558
2569
  }
2570
+ function DefaultCellHost(ctx) {
2571
+ const value = ctx.getValue();
2572
+ return value == null ? null : String(value);
2573
+ }
2559
2574
  function EditableCellHost(ctx) {
2560
2575
  const {
2561
2576
  editingCell,
@@ -2677,6 +2692,7 @@ var DENSITY_CLASS = {
2677
2692
  default: "",
2678
2693
  comfortable: "[&_tbody_td]:py-2"
2679
2694
  };
2695
+ var EMPTY_DATA = [];
2680
2696
  function DataTable({
2681
2697
  data,
2682
2698
  columns,
@@ -2700,6 +2716,12 @@ function DataTable({
2700
2716
  pageSizeOptions,
2701
2717
  initialColumnPinning,
2702
2718
  initialColumnVisibility,
2719
+ initialSorting,
2720
+ onSelectionChange,
2721
+ ariaLabel = "Data table",
2722
+ striped = false,
2723
+ stickyHeader = false,
2724
+ maxHeight,
2703
2725
  globalFilter: globalFilterProp,
2704
2726
  onGlobalFilterChange,
2705
2727
  className,
@@ -2776,7 +2798,7 @@ function DataTable({
2776
2798
  }
2777
2799
  setConfirmCtx(null);
2778
2800
  };
2779
- const [sorting, setSorting] = react.useState([]);
2801
+ const [sorting, setSorting] = react.useState(initialSorting ?? []);
2780
2802
  const [columnFilters, setColumnFilters] = react.useState([]);
2781
2803
  const [internalGlobalFilter, setInternalGlobalFilter] = react.useState("");
2782
2804
  const isControlledFilter = globalFilterProp !== void 0;
@@ -2872,7 +2894,7 @@ function DataTable({
2872
2894
  // eslint-disable-next-line react-hooks/exhaustive-deps
2873
2895
  ...dataSourceDeps
2874
2896
  ]);
2875
- const baseData = dataSourceEnabled ? sourceRows : data ?? [];
2897
+ const baseData = dataSourceEnabled ? sourceRows : data ?? EMPTY_DATA;
2876
2898
  const effectiveIsLoading = dataSourceEnabled ? sourceLoading : isLoading;
2877
2899
  const effectiveIsFetching = dataSourceEnabled ? sourceFetching : isFetching;
2878
2900
  const effectiveOnRefresh = dataSourceEnabled ? () => setSourceRefreshKey((key) => key + 1) : onRefresh;
@@ -2916,7 +2938,7 @@ function DataTable({
2916
2938
  title: headerLabel
2917
2939
  }
2918
2940
  ),
2919
- cell: hasEditor ? EditableCellHost : original
2941
+ cell: hasEditor ? EditableCellHost : original ?? DefaultCellHost
2920
2942
  };
2921
2943
  };
2922
2944
  const built = columns.map(wrap);
@@ -3028,10 +3050,10 @@ function DataTable({
3028
3050
  enableColumnFilters: f.filtering,
3029
3051
  enableColumnResizing: f.resizing,
3030
3052
  columnResizeMode: "onChange",
3031
- enableRowSelection: true,
3053
+ enableRowSelection: enableSelection,
3032
3054
  enableExpanding: !!(renderSubRow || getSubRows),
3033
3055
  enablePinning: f.pinning,
3034
- manualPagination: isServerSideDataSource,
3056
+ manualPagination: isServerSideDataSource || !f.pagination,
3035
3057
  manualSorting: isServerSideDataSource,
3036
3058
  manualFiltering: isServerSideDataSource,
3037
3059
  pageCount: isServerSideDataSource ? Math.max(1, Math.ceil(effectiveTotalRecords / pagination.pageSize)) : void 0,
@@ -3121,6 +3143,11 @@ function DataTable({
3121
3143
  setEditingRowId(tempId);
3122
3144
  } : void 0;
3123
3145
  const rows = table.getRowModel().rows;
3146
+ const selectionCallback = react.useRef(onSelectionChange);
3147
+ selectionCallback.current = onSelectionChange;
3148
+ react.useEffect(() => {
3149
+ selectionCallback.current?.(table.getSelectedRowModel().flatRows.map((row) => row.original));
3150
+ }, [rowSelection, mergedData, table]);
3124
3151
  const visibleLeafCount = table.getVisibleLeafColumns().length;
3125
3152
  const editingContextValue = react.useMemo(
3126
3153
  () => ({
@@ -3198,17 +3225,18 @@ function DataTable({
3198
3225
  labels
3199
3226
  }
3200
3227
  ),
3201
- /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative w-full overflow-auto", children: [
3228
+ /* @__PURE__ */ jsxRuntime.jsxs("div", { className: "relative w-full overflow-auto", style: { maxHeight }, children: [
3202
3229
  /* @__PURE__ */ jsxRuntime.jsxs(
3203
3230
  "table",
3204
3231
  {
3205
- className: "w-full caption-bottom text-sm",
3232
+ "aria-label": ariaLabel,
3233
+ className: cn("w-full caption-bottom text-sm", striped && "[&_tbody>tr:nth-child(even)]:bg-muted/25"),
3206
3234
  style: {
3207
3235
  width: table.getCenterTotalSize() ? void 0 : "100%",
3208
3236
  minWidth: "100%"
3209
3237
  },
3210
3238
  children: [
3211
- /* @__PURE__ */ jsxRuntime.jsx("thead", { className: "sticky top-0 z-10 bg-card/95 backdrop-blur supports-[backdrop-filter]:bg-card/70", children: table.getHeaderGroups().map((hg) => /* @__PURE__ */ jsxRuntime.jsx("tr", { className: "border-b", children: hg.headers.map((header) => {
3239
+ /* @__PURE__ */ jsxRuntime.jsx("thead", { className: cn("bg-card", stickyHeader && "sticky top-0 z-10"), children: table.getHeaderGroups().map((hg) => /* @__PURE__ */ jsxRuntime.jsx("tr", { className: "border-b", children: hg.headers.map((header) => {
3212
3240
  const meta = header.column.columnDef.meta;
3213
3241
  const isUtility = header.column.id === "__select" || header.column.id === "__expand" || header.column.id === "__actions";
3214
3242
  const isPinned = !!header.column.getIsPinned();
@@ -3401,6 +3429,7 @@ exports.exportToCsv = exportToCsv;
3401
3429
  exports.exportToExcel = exportToExcel;
3402
3430
  exports.isFilterActive = isFilterActive;
3403
3431
  exports.numberFilterFn = numberFilterFn;
3432
+ exports.sanitizeSpreadsheetValue = sanitizeSpreadsheetValue;
3404
3433
  exports.setFilterFn = setFilterFn;
3405
3434
  exports.splitTheme = splitTheme;
3406
3435
  exports.textFilterFn = textFilterFn;