@hexclave/dashboard-ui-components 1.0.3 → 1.0.6

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.
Files changed (98) hide show
  1. package/dist/components/alert.js +4 -4
  2. package/dist/components/alert.js.map +1 -1
  3. package/dist/components/badge.js +1 -1
  4. package/dist/components/badge.js.map +1 -1
  5. package/dist/components/button.d.ts +4 -4
  6. package/dist/components/button.js +3 -3
  7. package/dist/components/button.js.map +1 -1
  8. package/dist/components/data-grid/data-grid-toolbar.js +5 -5
  9. package/dist/components/data-grid/data-grid-toolbar.js.map +1 -1
  10. package/dist/components/data-grid/data-grid.d.ts.map +1 -1
  11. package/dist/components/data-grid/data-grid.js +11 -5
  12. package/dist/components/data-grid/data-grid.js.map +1 -1
  13. package/dist/components/data-grid/data-grid.test.js +34 -1
  14. package/dist/components/data-grid/data-grid.test.js.map +1 -1
  15. package/dist/components/dialog.d.ts +6 -2
  16. package/dist/components/dialog.d.ts.map +1 -1
  17. package/dist/components/dialog.js +14 -10
  18. package/dist/components/dialog.js.map +1 -1
  19. package/dist/components/input.js +12 -9
  20. package/dist/components/input.js.map +1 -1
  21. package/dist/components/pill-toggle.js +3 -3
  22. package/dist/components/pill-toggle.js.map +1 -1
  23. package/dist/components/tabs.js +2 -2
  24. package/dist/components/tabs.js.map +1 -1
  25. package/dist/dashboard-ui-components.global.js +15639 -17400
  26. package/dist/dashboard-ui-components.global.js.map +4 -4
  27. package/dist/esm/components/alert.js +4 -4
  28. package/dist/esm/components/alert.js.map +1 -1
  29. package/dist/esm/components/badge.js +1 -1
  30. package/dist/esm/components/badge.js.map +1 -1
  31. package/dist/esm/components/button.d.ts +4 -4
  32. package/dist/esm/components/button.js +3 -3
  33. package/dist/esm/components/button.js.map +1 -1
  34. package/dist/esm/components/data-grid/data-grid-toolbar.js +5 -5
  35. package/dist/esm/components/data-grid/data-grid-toolbar.js.map +1 -1
  36. package/dist/esm/components/data-grid/data-grid.d.ts.map +1 -1
  37. package/dist/esm/components/data-grid/data-grid.js +11 -5
  38. package/dist/esm/components/data-grid/data-grid.js.map +1 -1
  39. package/dist/esm/components/data-grid/data-grid.test.js +34 -1
  40. package/dist/esm/components/data-grid/data-grid.test.js.map +1 -1
  41. package/dist/esm/components/dialog.d.ts +6 -2
  42. package/dist/esm/components/dialog.d.ts.map +1 -1
  43. package/dist/esm/components/dialog.js +13 -11
  44. package/dist/esm/components/dialog.js.map +1 -1
  45. package/dist/esm/components/input.js +12 -9
  46. package/dist/esm/components/input.js.map +1 -1
  47. package/dist/esm/components/pill-toggle.js +4 -4
  48. package/dist/esm/components/pill-toggle.js.map +1 -1
  49. package/dist/esm/components/tabs.js +2 -2
  50. package/dist/esm/components/tabs.js.map +1 -1
  51. package/dist/esm/index.d.ts +2 -2
  52. package/dist/esm/index.js +2 -2
  53. package/dist/index.d.ts +2 -2
  54. package/dist/index.js +12 -0
  55. package/package.json +4 -3
  56. package/src/components/alert.tsx +120 -0
  57. package/src/components/analytics-chart/analytics-chart-pie.tsx +369 -0
  58. package/src/components/analytics-chart/analytics-chart.tsx +1585 -0
  59. package/src/components/analytics-chart/default-analytics-chart-tooltip.tsx +265 -0
  60. package/src/components/analytics-chart/format.ts +101 -0
  61. package/src/components/analytics-chart/index.ts +75 -0
  62. package/src/components/analytics-chart/palette.ts +68 -0
  63. package/src/components/analytics-chart/render-data-series.tsx +169 -0
  64. package/src/components/analytics-chart/state.ts +165 -0
  65. package/src/components/analytics-chart/strings.ts +72 -0
  66. package/src/components/analytics-chart/types.ts +220 -0
  67. package/src/components/badge.tsx +108 -0
  68. package/src/components/button.tsx +104 -0
  69. package/src/components/card.tsx +263 -0
  70. package/src/components/chart-card.tsx +101 -0
  71. package/src/components/chart-container.tsx +155 -0
  72. package/src/components/chart-legend.tsx +65 -0
  73. package/src/components/chart-theme.tsx +52 -0
  74. package/src/components/chart-tooltip.tsx +165 -0
  75. package/src/components/cursor-blast-effect.tsx +334 -0
  76. package/src/components/data-grid/data-grid-sizing.ts +51 -0
  77. package/src/components/data-grid/data-grid-toolbar.tsx +373 -0
  78. package/src/components/data-grid/data-grid.test.tsx +407 -0
  79. package/src/components/data-grid/data-grid.tsx +1231 -0
  80. package/src/components/data-grid/index.ts +64 -0
  81. package/src/components/data-grid/state.ts +235 -0
  82. package/src/components/data-grid/strings.ts +45 -0
  83. package/src/components/data-grid/types.ts +401 -0
  84. package/src/components/data-grid/use-data-source.ts +413 -0
  85. package/src/components/data-grid/use-url-state.test.tsx +88 -0
  86. package/src/components/data-grid/use-url-state.ts +298 -0
  87. package/src/components/dialog.tsx +218 -0
  88. package/src/components/edit-mode.tsx +17 -0
  89. package/src/components/empty-state.tsx +64 -0
  90. package/src/components/input.tsx +97 -0
  91. package/src/components/metric-card.tsx +142 -0
  92. package/src/components/pill-toggle.tsx +159 -0
  93. package/src/components/progress-bar.tsx +82 -0
  94. package/src/components/separator.tsx +36 -0
  95. package/src/components/skeleton.tsx +30 -0
  96. package/src/components/table.tsx +113 -0
  97. package/src/components/tabs.tsx +217 -0
  98. package/src/index.ts +71 -0
@@ -0,0 +1,413 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import type {
3
+ DataGridColumnDef,
4
+ DataGridDataSource,
5
+ DataGridFetchParams,
6
+ DataGridDataPaginationMode,
7
+ DataGridPaginationModel,
8
+ DataGridSortModel,
9
+ RowId,
10
+ } from "./types";
11
+ import {
12
+ applyQuickSearch,
13
+ buildRowComparator,
14
+ defaultMatchRow,
15
+ paginateRows,
16
+ } from "./state";
17
+
18
+ export type UseDataSourceResult<TRow> = {
19
+ /** All rows currently loaded (for infinite mode, the accumulated set). */
20
+ rows: readonly TRow[];
21
+ /** Total row count if known. */
22
+ totalRowCount: number | undefined;
23
+ /** Whether the initial load is in progress (no data at all yet). */
24
+ isLoading: boolean;
25
+ /** Whether a background refetch is happening (data already shown). */
26
+ isRefetching: boolean;
27
+ /** Whether more rows are being fetched (infinite scroll). */
28
+ isLoadingMore: boolean;
29
+ /** Request the next page (infinite scroll). */
30
+ loadMore: () => void;
31
+ /** Whether there are more pages to load. */
32
+ hasMore: boolean;
33
+ /** Reload from scratch. */
34
+ reload: () => void;
35
+ /**
36
+ * Error from the most recent async fetch, if any. Consumers should render
37
+ * an error UI and offer a `reload()` button when this is non-null. Client
38
+ * mode never sets this (no fetching). Cleared on next successful fetch.
39
+ */
40
+ error: Error | null;
41
+ };
42
+
43
+ // ─── Client-side hook ────────────────────────────────────────────────
44
+ // Memoised so resize / selection / other unrelated state changes
45
+ // don't recompute or create new array references.
46
+
47
+ function useClientDataSource<TRow>(opts: {
48
+ data: readonly TRow[];
49
+ columns: readonly DataGridColumnDef<TRow>[];
50
+ sorting: DataGridSortModel;
51
+ quickSearch: string;
52
+ matchRow: (
53
+ row: TRow,
54
+ query: string,
55
+ columns: readonly DataGridColumnDef<TRow>[],
56
+ ) => boolean;
57
+ pagination: DataGridPaginationModel;
58
+ paginationMode: DataGridDataPaginationMode;
59
+ }): UseDataSourceResult<TRow> {
60
+ const { data, columns, sorting, quickSearch, matchRow, pagination, paginationMode } = opts;
61
+
62
+ // Stable serialised keys so useMemo only fires on real changes
63
+ const sortingKey = JSON.stringify(sorting);
64
+
65
+ const processed = useMemo(() => {
66
+ // Quick search is applied FIRST, on the full input. If nothing is
67
+ // typed this is a zero-cost no-op (applyQuickSearch returns the
68
+ // original array reference). Sort and paginate operate on the
69
+ // already-filtered set so the result counts are search-aware.
70
+ const searched = applyQuickSearch(data, quickSearch, columns, matchRow);
71
+ const comparator = buildRowComparator(sorting, columns);
72
+ const sorted = comparator ? [...searched].sort(comparator) : searched;
73
+ const totalRowCount = sorted.length;
74
+ const paged =
75
+ paginationMode === "client"
76
+ ? paginateRows(sorted as readonly TRow[], pagination)
77
+ : sorted;
78
+ return { rows: paged, totalRowCount };
79
+ }, [data, sortingKey, quickSearch, matchRow, pagination.pageIndex, pagination.pageSize, paginationMode, columns]);
80
+
81
+ return useMemo(() => ({
82
+ rows: processed.rows,
83
+ totalRowCount: processed.totalRowCount,
84
+ isLoading: false,
85
+ isRefetching: false,
86
+ isLoadingMore: false,
87
+ loadMore: () => {},
88
+ hasMore: false,
89
+ reload: () => {},
90
+ error: null,
91
+ }), [processed]);
92
+ }
93
+
94
+ // ─── Async data source hook ──────────────────────────────────────────
95
+ // Key behaviour: when refetching (sort change), we keep showing the old
96
+ // rows and set `isRefetching` instead of `isLoading`. This avoids the
97
+ // jarring flash-to-skeleton on every sort toggle.
98
+
99
+ function useAsyncDataSource<TRow>(opts: {
100
+ dataSource: DataGridDataSource<TRow>;
101
+ getRowId: (row: TRow) => RowId;
102
+ sorting: DataGridSortModel;
103
+ quickSearch: string;
104
+ pagination: DataGridPaginationModel;
105
+ paginationMode: DataGridDataPaginationMode;
106
+ }): UseDataSourceResult<TRow> {
107
+ const {
108
+ dataSource,
109
+ getRowId,
110
+ sorting,
111
+ quickSearch,
112
+ pagination,
113
+ paginationMode,
114
+ } = opts;
115
+
116
+ const [rows, setRows] = useState<TRow[]>([]);
117
+ const [totalRowCount, setTotalRowCount] = useState<number | undefined>(undefined);
118
+ const [isLoading, setIsLoading] = useState(true);
119
+ const [isRefetching, setIsRefetching] = useState(false);
120
+ const [isLoadingMore, setIsLoadingMore] = useState(false);
121
+ const [hasMore, setHasMore] = useState(true);
122
+ const [error, setError] = useState<Error | null>(null);
123
+
124
+ const cursorRef = useRef<unknown>(undefined);
125
+ const abortRef = useRef<AbortController | null>(null);
126
+ const pageIndexRef = useRef(0);
127
+ const hasDataRef = useRef(false);
128
+ const hasMountedServerPaginationRef = useRef(false);
129
+
130
+ const latestArgsRef = useRef({
131
+ dataSource,
132
+ getRowId,
133
+ sorting,
134
+ quickSearch,
135
+ pagination,
136
+ });
137
+ latestArgsRef.current = { dataSource, getRowId, sorting, quickSearch, pagination };
138
+
139
+ const sortingKey = JSON.stringify(sorting);
140
+ const quickSearchKey = quickSearch;
141
+
142
+ const fetchPage = useCallback(
143
+ async (append: boolean) => {
144
+ const {
145
+ dataSource: currentDataSource,
146
+ getRowId: currentGetRowId,
147
+ sorting: currentSorting,
148
+ quickSearch: currentQuickSearch,
149
+ pagination: currentPagination,
150
+ } = latestArgsRef.current;
151
+
152
+ abortRef.current?.abort();
153
+ const controller = new AbortController();
154
+ abortRef.current = controller;
155
+
156
+ if (append) {
157
+ setIsLoadingMore(true);
158
+ } else {
159
+ // First load → skeleton. Subsequent → subtle refetch indicator.
160
+ if (hasDataRef.current) {
161
+ setIsRefetching(true);
162
+ } else {
163
+ setIsLoading(true);
164
+ }
165
+ cursorRef.current = undefined;
166
+ pageIndexRef.current = 0;
167
+ }
168
+ // Clear previous error at the start of a new attempt; we'll set it
169
+ // again if this attempt fails.
170
+ setError(null);
171
+
172
+ try {
173
+ const params: DataGridFetchParams = {
174
+ sorting: currentSorting,
175
+ quickSearch: currentQuickSearch,
176
+ pagination: append
177
+ ? { pageIndex: pageIndexRef.current, pageSize: currentPagination.pageSize }
178
+ : currentPagination,
179
+ cursor: cursorRef.current,
180
+ };
181
+
182
+ const gen = currentDataSource(params);
183
+
184
+ for await (const result of gen) {
185
+ if (controller.signal.aborted) return;
186
+
187
+ if (result.totalRowCount != null) {
188
+ setTotalRowCount(result.totalRowCount);
189
+ }
190
+ if (result.nextCursor !== undefined) {
191
+ cursorRef.current = result.nextCursor;
192
+ }
193
+ setHasMore(result.hasMore !== false);
194
+
195
+ if (append) {
196
+ setRows((prev) => {
197
+ const existingIds = new Set(prev.map(currentGetRowId));
198
+ const newRows = result.rows.filter(
199
+ (r) => !existingIds.has(currentGetRowId(r)),
200
+ );
201
+ return [...prev, ...newRows];
202
+ });
203
+ } else {
204
+ setRows(result.rows);
205
+ }
206
+
207
+ hasDataRef.current = true;
208
+ pageIndexRef.current++;
209
+ }
210
+ } catch (err) {
211
+ if (controller.signal.aborted) return;
212
+ // Surface the error on the result so consumers can render retry UI.
213
+ // Still log to console so it's visible in dev without forcing every
214
+ // consumer to wire up error rendering.
215
+ // eslint-disable-next-line no-console
216
+ console.error("[DataGrid] Data source error:", err);
217
+ setError(err instanceof Error ? err : new Error(String(err)));
218
+ } finally {
219
+ if (!controller.signal.aborted) {
220
+ setIsLoading(false);
221
+ setIsRefetching(false);
222
+ setIsLoadingMore(false);
223
+ }
224
+ }
225
+ },
226
+ [],
227
+ );
228
+
229
+ useEffect(() => {
230
+ fetchPage(false).catch(() => {});
231
+ return () => abortRef.current?.abort();
232
+ // Also refetches when `dataSource` identity changes — consumers encode
233
+ // external filter state into the generator's closure, so a new
234
+ // generator reference is the signal that the query changed.
235
+ }, [fetchPage, dataSource, sortingKey, quickSearchKey, pagination.pageSize]);
236
+
237
+ useEffect(() => {
238
+ if (paginationMode !== "server") {
239
+ hasMountedServerPaginationRef.current = false;
240
+ return;
241
+ }
242
+ if (!hasMountedServerPaginationRef.current) {
243
+ hasMountedServerPaginationRef.current = true;
244
+ return;
245
+ }
246
+ fetchPage(false).catch(() => {});
247
+ }, [fetchPage, paginationMode, pagination.pageIndex]);
248
+
249
+ const loadMore = useCallback(() => {
250
+ if (!isLoadingMore && hasMore && paginationMode === "infinite") {
251
+ fetchPage(true).catch(() => {});
252
+ }
253
+ }, [isLoadingMore, hasMore, paginationMode, fetchPage]);
254
+
255
+ const reload = useCallback(() => {
256
+ fetchPage(false).catch(() => {});
257
+ }, [fetchPage]);
258
+
259
+ return {
260
+ rows,
261
+ totalRowCount,
262
+ isLoading,
263
+ isRefetching,
264
+ isLoadingMore,
265
+ loadMore,
266
+ hasMore,
267
+ reload,
268
+ error,
269
+ };
270
+ }
271
+
272
+ // ─── Noop data source (stable reference) ─────────────────────────────
273
+ const NOOP_DATA_SOURCE: DataGridDataSource<any> = async function* () {};
274
+ const NOOP_GET_ROW_ID = () => "";
275
+
276
+ // ─── Public hook ─────────────────────────────────────────────────────
277
+ // Both inner hooks are always called (React rules-of-hooks) but only
278
+ // one provides the returned result.
279
+
280
+ /**
281
+ * Hook that processes raw data through the grid's sort/pagination state
282
+ * and returns the `rows` slice ready to pass to `DataGrid`. This is the
283
+ * only correct way to feed client-side data into a grid.
284
+ *
285
+ * Two modes, picked by which prop you pass:
286
+ * - `data: TRow[]` → client-side mode. In-memory sort + paginate.
287
+ * - `dataSource: (params) => AsyncGenerator` → server / infinite mode.
288
+ * The generator yields pages as you scroll or change pages.
289
+ *
290
+ * ```tsx
291
+ * // Client-side (most common):
292
+ * const gridData = useDataSource({
293
+ * data: users,
294
+ * columns,
295
+ * getRowId: (row) => row.id,
296
+ * sorting: gridState.sorting,
297
+ * quickSearch: gridState.quickSearch,
298
+ * pagination: gridState.pagination,
299
+ * paginationMode: "client",
300
+ * });
301
+ *
302
+ * <DataGrid
303
+ * columns={columns}
304
+ * rows={gridData.rows}
305
+ * totalRowCount={gridData.totalRowCount}
306
+ * isLoading={gridData.isLoading}
307
+ * state={gridState}
308
+ * onChange={setGridState}
309
+ * getRowId={(row) => row.id}
310
+ * />
311
+ * ```
312
+ *
313
+ * Rules:
314
+ * - Call this hook unconditionally at the top level, before any early return.
315
+ * - `rows` on `DataGrid` must ALWAYS be `gridData.rows`, never your raw array.
316
+ * - For server or infinite pagination, use `dataSource` — see the
317
+ * `DataGridDataSource` type for the generator signature.
318
+ *
319
+ * Quick search:
320
+ * - Client mode (`data` prop): the hook auto-filters rows via
321
+ * `applyQuickSearch` using a default case-insensitive substring match
322
+ * across every column. Override with `matchRow` for custom matching
323
+ * (fuzzy, weighted, field-specific, etc.).
324
+ * - Async mode (`dataSource` prop): the hook passes `quickSearch` into
325
+ * `params.quickSearch` and re-runs the generator whenever the search
326
+ * string changes. The consumer owns the matching logic (typically by
327
+ * folding it into a backend query). The grid performs NO client-side
328
+ * filtering in async mode.
329
+ */
330
+ export function useDataSource<TRow>(opts: {
331
+ data?: readonly TRow[];
332
+ dataSource?: DataGridDataSource<TRow>;
333
+ columns: readonly DataGridColumnDef<TRow>[];
334
+ getRowId: (row: TRow) => RowId;
335
+ sorting: DataGridSortModel;
336
+ /** Current quick-search text, typically `gridState.quickSearch`. */
337
+ quickSearch: string;
338
+ /** Override the default client-mode matcher. Ignored in async mode
339
+ * (there the generator is the matcher). */
340
+ matchRow?: (
341
+ row: TRow,
342
+ query: string,
343
+ columns: readonly DataGridColumnDef<TRow>[],
344
+ ) => boolean;
345
+ pagination: DataGridPaginationModel;
346
+ paginationMode: DataGridDataPaginationMode;
347
+ }): UseDataSourceResult<TRow> {
348
+ const {
349
+ data,
350
+ dataSource,
351
+ columns,
352
+ getRowId,
353
+ sorting,
354
+ quickSearch,
355
+ matchRow = defaultMatchRow,
356
+ pagination,
357
+ paginationMode,
358
+ } = opts;
359
+
360
+ const isClientMode = data != null && !dataSource;
361
+
362
+ if (process.env.NODE_ENV !== "production" && data == null && dataSource == null) {
363
+ // eslint-disable-next-line no-console
364
+ console.warn(
365
+ "[useDataSource] neither `data` nor `dataSource` was provided — "
366
+ + "the grid will render empty indefinitely. Pass one or the other."
367
+ );
368
+ }
369
+
370
+ // Common footgun: consumers pass `data` as a fully-materialized array and
371
+ // set `paginationMode: "infinite"` expecting the grid to page through it.
372
+ // In client mode "infinite" skips `paginateRows` and returns every row;
373
+ // `hasMore` / `loadMore` on the result are always false/no-ops. If you
374
+ // want real paging, switch to `paginationMode: "server"` with a
375
+ // `dataSource` generator. If you want in-memory slicing, use `"client"`.
376
+ // If you're manually accumulating rows into `data` and driving the grid's
377
+ // sentinel via your own `hasMore`/`onLoadMore`, this warning is a hint —
378
+ // but current behavior (full list + external sentinel) is intentional.
379
+ if (
380
+ process.env.NODE_ENV !== "production"
381
+ && isClientMode
382
+ && paginationMode === "infinite"
383
+ && data.length > 0
384
+ ) {
385
+ // eslint-disable-next-line no-console
386
+ console.warn(
387
+ "[useDataSource] `paginationMode: \"infinite\"` with a `data` array "
388
+ + "skips pagination entirely. Prefer `\"client\"` for in-memory lists "
389
+ + "or `\"server\"` + a `dataSource` generator for real paging."
390
+ );
391
+ }
392
+
393
+ const clientResult = useClientDataSource({
394
+ data: data ?? [],
395
+ columns,
396
+ sorting,
397
+ quickSearch,
398
+ matchRow,
399
+ pagination,
400
+ paginationMode,
401
+ });
402
+
403
+ const asyncResult = useAsyncDataSource({
404
+ dataSource: dataSource ?? NOOP_DATA_SOURCE,
405
+ getRowId: dataSource ? getRowId : NOOP_GET_ROW_ID,
406
+ sorting,
407
+ quickSearch,
408
+ pagination,
409
+ paginationMode,
410
+ });
411
+
412
+ return isClientMode ? clientResult : asyncResult;
413
+ }
@@ -0,0 +1,88 @@
1
+ // @vitest-environment jsdom
2
+
3
+ import { act, renderHook } from "@testing-library/react";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import type { DataGridColumnDef } from "./types";
6
+ import { useDataGridUrlState } from "./use-url-state";
7
+
8
+ type Row = { id: string };
9
+
10
+ const columns: DataGridColumnDef<Row>[] = [
11
+ { id: "name", header: "Name", accessor: () => "", width: 160, minWidth: 80, type: "string" },
12
+ { id: "email", header: "Email", accessor: () => "", width: 200, minWidth: 80, type: "string" },
13
+ ];
14
+
15
+ function setUrl(search: string) {
16
+ window.history.replaceState(null, "", `/${search ? `?${search}` : ""}`);
17
+ }
18
+
19
+ beforeEach(() => {
20
+ setUrl("");
21
+ });
22
+
23
+ afterEach(() => {
24
+ setUrl("");
25
+ });
26
+
27
+ describe("useDataGridUrlState", () => {
28
+ it("initializes from existing URL params", () => {
29
+ setUrl("grid_w=name:240&grid_h=email");
30
+ const { result } = renderHook(() => useDataGridUrlState(columns));
31
+ const [state] = result.current;
32
+ expect(state.columnWidths.name).toBe(240);
33
+ expect(state.columnVisibility.email).toBe(false);
34
+ });
35
+
36
+ it("writes width changes back to the URL (after debounce)", async () => {
37
+ const { result } = renderHook(() => useDataGridUrlState(columns));
38
+
39
+ act(() => {
40
+ const [, setState] = result.current;
41
+ setState((prev) => ({
42
+ ...prev,
43
+ columnWidths: { ...prev.columnWidths, name: 250 },
44
+ }));
45
+ });
46
+
47
+ // Debounce is 100ms.
48
+ await new Promise((resolve) => setTimeout(resolve, 150));
49
+ const params = new URLSearchParams(window.location.search);
50
+ expect(params.get("grid_w")).toBe("name:250");
51
+ });
52
+
53
+ it("resets column widths to defaults when a popstate clears the URL", () => {
54
+ setUrl("grid_w=name:300");
55
+ const { result } = renderHook(() => useDataGridUrlState(columns));
56
+ expect(result.current[0].columnWidths.name).toBe(300);
57
+
58
+ act(() => {
59
+ setUrl("");
60
+ window.dispatchEvent(new PopStateEvent("popstate"));
61
+ });
62
+
63
+ // Default for "name" column is its declared width of 160.
64
+ expect(result.current[0].columnWidths.name).toBe(160);
65
+ });
66
+
67
+ it("isolates two grids on the same page via paramPrefix", async () => {
68
+ const { result: a } = renderHook(() => useDataGridUrlState(columns, { paramPrefix: "users" }));
69
+ const { result: b } = renderHook(() => useDataGridUrlState(columns, { paramPrefix: "teams" }));
70
+
71
+ act(() => {
72
+ a.current[1]((prev) => ({ ...prev, columnWidths: { ...prev.columnWidths, name: 222 } }));
73
+ });
74
+ await new Promise((resolve) => setTimeout(resolve, 150));
75
+
76
+ const params = new URLSearchParams(window.location.search);
77
+ expect(params.get("users_w")).toBe("name:222");
78
+ expect(params.get("teams_w")).toBeNull();
79
+ expect(b.current[0].columnWidths.name).toBe(160); // unaffected
80
+ });
81
+
82
+ it("ignores malformed entries in the URL param without throwing", () => {
83
+ setUrl("grid_w=:,name:abc,name:240,bogusid:99");
84
+ const { result } = renderHook(() => useDataGridUrlState(columns));
85
+ // Only the well-formed `name:240` should land; junk is dropped silently.
86
+ expect(result.current[0].columnWidths.name).toBe(240);
87
+ });
88
+ });