aplance-ui 1.0.1

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 (132) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/dist/api.d.ts +175 -0
  4. package/dist/api.js +1954 -0
  5. package/dist/api.js.map +1 -0
  6. package/dist/frameworks/astro/astro.config.snippet.md +23 -0
  7. package/dist/frameworks/astro/postcss.config.mjs.template +20 -0
  8. package/dist/frameworks/astro/vite-inline.snippet.md +22 -0
  9. package/dist/frameworks/electron-vite/vite.config.snippet.md +44 -0
  10. package/dist/frameworks/next/layout-import.snippet.md +19 -0
  11. package/dist/frameworks/next/next.config.snippet.md +15 -0
  12. package/dist/frameworks/next/postcss.config.js.template +16 -0
  13. package/dist/frameworks/tanstack-start/postcss.config.ts.template +17 -0
  14. package/dist/frameworks/tanstack-start/vite-inline.snippet.md +22 -0
  15. package/dist/frameworks/vite-react/postcss.config.ts.template +16 -0
  16. package/dist/frameworks/vite-react/vite-inline.snippet.md +20 -0
  17. package/dist/frameworks/vite-react/vite.config.ts.template +48 -0
  18. package/dist/index.js +2047 -0
  19. package/dist/index.js.map +1 -0
  20. package/package.json +63 -0
  21. package/registry/components/primitives/icon/icon.module.css +63 -0
  22. package/registry/components/primitives/icon/icon.tsx +53 -0
  23. package/registry/components/primitives/modal/modal-context.ts +19 -0
  24. package/registry/components/primitives/modal/modal.module.css +415 -0
  25. package/registry/components/primitives/modal/modal.tsx +550 -0
  26. package/registry/components/primitives/popover/popover-context.ts +28 -0
  27. package/registry/components/primitives/popover/popover.module.css +28 -0
  28. package/registry/components/primitives/popover/popover.tsx +465 -0
  29. package/registry/components/ui/accordion/accordion.module.css +208 -0
  30. package/registry/components/ui/accordion/accordion.tsx +327 -0
  31. package/registry/components/ui/avatar/avatar.module.css +32 -0
  32. package/registry/components/ui/avatar/avatar.tsx +37 -0
  33. package/registry/components/ui/badge/badge.module.css +103 -0
  34. package/registry/components/ui/badge/badge.tsx +29 -0
  35. package/registry/components/ui/button/button.module.css +216 -0
  36. package/registry/components/ui/button/button.tsx +133 -0
  37. package/registry/components/ui/chart/chart.module.css +168 -0
  38. package/registry/components/ui/chart/chart.tsx +1293 -0
  39. package/registry/components/ui/chart/types.ts +210 -0
  40. package/registry/components/ui/chart/utils.ts +59 -0
  41. package/registry/components/ui/copy-button/copy-button.module.css +21 -0
  42. package/registry/components/ui/copy-button/copy-button.tsx +123 -0
  43. package/registry/components/ui/countdown/countdown.module.css +5 -0
  44. package/registry/components/ui/countdown/countdown.tsx +152 -0
  45. package/registry/components/ui/data-table/checkbox-column.tsx +59 -0
  46. package/registry/components/ui/data-table/data-table.module.css +594 -0
  47. package/registry/components/ui/data-table/data-table.tsx +1109 -0
  48. package/registry/components/ui/data-table/filter-bar.tsx +33 -0
  49. package/registry/components/ui/data-table/pagination-bar.tsx +125 -0
  50. package/registry/components/ui/data-table/types.ts +594 -0
  51. package/registry/components/ui/data-table/useDataTable.tsx +573 -0
  52. package/registry/components/ui/data-table/utils.ts +445 -0
  53. package/registry/components/ui/empty-state/empty-state.module.css +26 -0
  54. package/registry/components/ui/empty-state/empty-state.tsx +32 -0
  55. package/registry/components/ui/input-fields/color-picker/color-palette.module.css +102 -0
  56. package/registry/components/ui/input-fields/color-picker/color-palette.tsx +92 -0
  57. package/registry/components/ui/input-fields/color-picker/color-picker.module.css +29 -0
  58. package/registry/components/ui/input-fields/color-picker/color-picker.tsx +168 -0
  59. package/registry/components/ui/input-fields/common/field-message/field-message.module.css +28 -0
  60. package/registry/components/ui/input-fields/common/field-message/field-message.tsx +39 -0
  61. package/registry/components/ui/input-fields/common/input-field-common.module.css +318 -0
  62. package/registry/components/ui/input-fields/common/input-field-native.module.css +267 -0
  63. package/registry/components/ui/input-fields/common/picker-popover-common.module.css +40 -0
  64. package/registry/components/ui/input-fields/common/select-button/select-button.module.css +9 -0
  65. package/registry/components/ui/input-fields/common/select-button/select-button.tsx +171 -0
  66. package/registry/components/ui/input-fields/common/types.ts +38 -0
  67. package/registry/components/ui/input-fields/custom-select/custom-select.module.css +56 -0
  68. package/registry/components/ui/input-fields/custom-select/custom-select.tsx +403 -0
  69. package/registry/components/ui/input-fields/date-picker/calendar.module.css +311 -0
  70. package/registry/components/ui/input-fields/date-picker/calendar.tsx +186 -0
  71. package/registry/components/ui/input-fields/date-picker/date-picker.module.css +57 -0
  72. package/registry/components/ui/input-fields/date-picker/date-picker.tsx +945 -0
  73. package/registry/components/ui/input-fields/date-picker/types.ts +102 -0
  74. package/registry/components/ui/input-fields/date-picker/utils.ts +236 -0
  75. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.module.css +90 -0
  76. package/registry/components/ui/input-fields/emoji-picker/emoji-palette.tsx +168 -0
  77. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.module.css +8 -0
  78. package/registry/components/ui/input-fields/emoji-picker/emoji-picker.tsx +102 -0
  79. package/registry/components/ui/input-fields/emoji-picker/utils.ts +505 -0
  80. package/registry/components/ui/input-fields/field-group/field-group.module.css +86 -0
  81. package/registry/components/ui/input-fields/field-group/field-group.tsx +86 -0
  82. package/registry/components/ui/input-fields/input/input.tsx +319 -0
  83. package/registry/components/ui/input-fields/media-input/kind-icons.tsx +62 -0
  84. package/registry/components/ui/input-fields/media-input/media-input.module.css +294 -0
  85. package/registry/components/ui/input-fields/media-input/media-input.tsx +327 -0
  86. package/registry/components/ui/input-fields/media-input/media-preview.tsx +101 -0
  87. package/registry/components/ui/input-fields/media-input/types.ts +129 -0
  88. package/registry/components/ui/input-fields/media-input/useMediaUpload.tsx +302 -0
  89. package/registry/components/ui/input-fields/media-input/utils.ts +325 -0
  90. package/registry/components/ui/input-fields/otp-input/otp-input.module.css +24 -0
  91. package/registry/components/ui/input-fields/otp-input/otp-input.tsx +291 -0
  92. package/registry/components/ui/input-fields/select/select.tsx +118 -0
  93. package/registry/components/ui/input-fields/switch/switch.module.css +70 -0
  94. package/registry/components/ui/input-fields/switch/switch.tsx +59 -0
  95. package/registry/components/ui/input-fields/textarea/textarea.tsx +133 -0
  96. package/registry/components/ui/input-fields/time-picker/time-picker.module.css +57 -0
  97. package/registry/components/ui/input-fields/time-picker/time-picker.tsx +437 -0
  98. package/registry/components/ui/loader/loader.module.css +156 -0
  99. package/registry/components/ui/loader/loader.tsx +102 -0
  100. package/registry/components/ui/stepper/stepper.module.css +294 -0
  101. package/registry/components/ui/stepper/stepper.tsx +197 -0
  102. package/registry/components/ui/tabs/tabs.module.css +150 -0
  103. package/registry/components/ui/tabs/tabs.tsx +134 -0
  104. package/registry/components/ui/toast/toast.module.css +159 -0
  105. package/registry/components/ui/toast/toast.tsx +104 -0
  106. package/registry/components/ui/tooltip/tooltip.module.css +82 -0
  107. package/registry/components/ui/tooltip/tooltip.tsx +72 -0
  108. package/registry/global.d.ts +4 -0
  109. package/registry/hooks/useFocusOutside.tsx +33 -0
  110. package/registry/hooks/useResizeObserver.tsx +32 -0
  111. package/registry/lib/dom.ts +25 -0
  112. package/registry/lib/string.ts +23 -0
  113. package/registry/registry.json +719 -0
  114. package/registry/styles/app.css +240 -0
  115. package/registry/styles/components.css +78 -0
  116. package/registry/styles/fonts/geist-regular.woff2 +0 -0
  117. package/registry/styles/globals.css +7 -0
  118. package/registry/styles/media.css +25 -0
  119. package/registry/styles/utilities.css +90 -0
  120. package/registry/themes/colors/blue.css +37 -0
  121. package/registry/themes/colors/green.css +37 -0
  122. package/registry/themes/colors/rose.css +37 -0
  123. package/registry/themes/colors/slate.css +37 -0
  124. package/registry/themes/colors/stone.css +37 -0
  125. package/registry/themes/colors/violet.css +37 -0
  126. package/registry/themes/colors/zinc.css +37 -0
  127. package/registry/themes/radius/pill.css +8 -0
  128. package/registry/themes/radius/rounded.css +8 -0
  129. package/registry/themes/radius/sharp.css +8 -0
  130. package/registry/themes/spacing/compact.css +17 -0
  131. package/registry/themes/spacing/spacious.css +17 -0
  132. package/registry/tsconfig.json +25 -0
@@ -0,0 +1,573 @@
1
+ import { useState, useMemo, useCallback, useEffect, useRef } from "react";
2
+ import type {
3
+ ColumnOrderState,
4
+ ColumnPinningState,
5
+ ColumnSizingState,
6
+ ExpandedState,
7
+ GroupingState,
8
+ OnChangeFn,
9
+ RowSelectionState,
10
+ SortingState,
11
+ VisibilityState,
12
+ } from "@tanstack/react-table";
13
+ import type {
14
+ DataTableProps,
15
+ PaginationState,
16
+ PersistArgs,
17
+ PersistedState,
18
+ UseDataTableOptions,
19
+ UseDataTableReturn,
20
+ } from "./types";
21
+ import {
22
+ applyPersistedState,
23
+ readLocalStorage,
24
+ readSearchParams,
25
+ snapshotPersistedState,
26
+ writeLocalStorage,
27
+ writeSearchParams,
28
+ } from "./utils";
29
+
30
+ // Stable empty-object reference - avoids resetting useState's initializer on
31
+ // every render when the consumer doesn't pass `defaultFilterValues`.
32
+ const EMPTY_FILTERS: Record<string, string> = {};
33
+
34
+ // ─── Hook ─────────────────────────────────────────────────────────────────────
35
+
36
+ export function useDataTable<TData extends { id: string }>({
37
+ data,
38
+ pageSize: initialPageSize = 10,
39
+ defaultFilterValues = EMPTY_FILTERS,
40
+ filterFn,
41
+ onFilterChange,
42
+ onPaginationChange: externalOnPaginationChange,
43
+ onSortingChange: externalOnSortingChange,
44
+ onRowSelectionChange: externalOnRowSelectionChange,
45
+ onDataChange,
46
+ clientSidePagination = true,
47
+ manualSorting = false,
48
+ totalCount: externalTotalCount,
49
+ totalPages: externalTotalPages,
50
+ defaultColumnOrder,
51
+ defaultColumnVisibility,
52
+ defaultColumnPinning,
53
+ defaultColumnSizing,
54
+ defaultGrouping,
55
+ defaultExpanded,
56
+ persistState,
57
+ }: UseDataTableOptions<TData>): UseDataTableReturn<TData> {
58
+ // ── Internal state ──────────────────────────────────────────────────────────
59
+ const [sorting, setSorting] = useState<SortingState>([]);
60
+ const [pagination, setPagination] = useState<PaginationState>({
61
+ pageIndex: 0,
62
+ pageSize: initialPageSize,
63
+ });
64
+ const [filterValues, setFilterValues] =
65
+ useState<Record<string, string>>(defaultFilterValues);
66
+ const [rowSelection, setRowSelection] = useState<RowSelectionState>({});
67
+ const [editingRowId, setEditingRowId] = useState<string | null>(null);
68
+ const [columnOrder, setColumnOrderState] = useState<ColumnOrderState>(
69
+ defaultColumnOrder ?? [],
70
+ );
71
+ const [columnVisibility, setColumnVisibilityState] =
72
+ useState<VisibilityState>(defaultColumnVisibility ?? {});
73
+ const [columnPinning, setColumnPinningState] = useState<ColumnPinningState>(
74
+ defaultColumnPinning ?? {},
75
+ );
76
+ const [columnSizing, setColumnSizingState] = useState<ColumnSizingState>(
77
+ defaultColumnSizing ?? {},
78
+ );
79
+ const [grouping, setGroupingState] = useState<GroupingState>(
80
+ defaultGrouping ?? [],
81
+ );
82
+ const [expanded, setExpandedState] = useState<ExpandedState>(
83
+ defaultExpanded ?? true,
84
+ );
85
+ // snapshot for cancel-edit (ref keeps callbacks stable, avoids extra re-renders)
86
+ const snapshotRef = useRef<TData | null>(null);
87
+ // mutable data copy for inline editing
88
+ const [localData, setLocalData] = useState<TData[]>(data);
89
+ const [prevData, setPrevData] = useState<TData[]>(data);
90
+ const localDataRef = useRef<TData[]>(data);
91
+
92
+ // Latest-value refs - let handlers compute `next` outside of the setState
93
+ // updater. Firing consumer callbacks inside an updater causes double-fires
94
+ // under React strict/concurrent rendering (updaters may run twice) which
95
+ // would trigger duplicate API calls for server-side sort/filter consumers.
96
+ const sortingRef = useRef(sorting);
97
+ const rowSelectionRef = useRef(rowSelection);
98
+ const filterValuesRef = useRef(filterValues);
99
+ const paginationRef = useRef(pagination);
100
+ useEffect(() => {
101
+ sortingRef.current = sorting;
102
+ rowSelectionRef.current = rowSelection;
103
+ filterValuesRef.current = filterValues;
104
+ paginationRef.current = pagination;
105
+ }, [sorting, rowSelection, filterValues, pagination]);
106
+
107
+ // Keep localData in sync when the outer data prop changes (new fetch result
108
+ // arrives), but only when not editing - overwriting in-progress edits would
109
+ // discard the user's input. We hold off on advancing `prevData` while
110
+ // editing so a `data` change *during* an edit isn't forgotten: the next
111
+ // run after `editingRowId` flips back to null still sees prevData!==data and
112
+ // syncs.
113
+ // We do this during render (instead of useEffect) to prevent a micro-second
114
+ // jerk/overlap of old data when server-side fetch completes.
115
+ // A shallow array comparison prevents infinite re-render loops when consumers
116
+ // pass inline literal arrays (like `data={[]}`) which change reference every render.
117
+ if (editingRowId === null) {
118
+ let hasChanged = prevData !== data;
119
+ if (hasChanged && prevData?.length === data?.length) {
120
+ hasChanged = false;
121
+ for (let i = 0; i < (data?.length || 0); i++) {
122
+ if (prevData[i] !== data[i]) {
123
+ hasChanged = true;
124
+ break;
125
+ }
126
+ }
127
+ }
128
+ if (hasChanged) {
129
+ setPrevData(data);
130
+ setLocalData(data);
131
+ }
132
+ }
133
+
134
+ useEffect(() => {
135
+ localDataRef.current = localData;
136
+ }, [localData]);
137
+
138
+ // ── Sorting handler ─────────────────────────────────────────────────────────
139
+ const handleSortingChange: OnChangeFn<SortingState> = useCallback(
140
+ (updater) => {
141
+ const next =
142
+ typeof updater === "function" ? updater(sortingRef.current) : updater;
143
+ setSorting(next);
144
+ externalOnSortingChange?.(next);
145
+ },
146
+ [externalOnSortingChange],
147
+ );
148
+
149
+ // ── Pagination handler ──────────────────────────────────────────────────────
150
+ const handlePaginationChange = useCallback(
151
+ (next: PaginationState) => {
152
+ setPagination(next);
153
+ externalOnPaginationChange?.(next);
154
+ },
155
+ [externalOnPaginationChange],
156
+ );
157
+
158
+ // ── Row selection handler ───────────────────────────────────────────────────
159
+ const handleRowSelectionChange: OnChangeFn<RowSelectionState> = useCallback(
160
+ (updater) => {
161
+ const next =
162
+ typeof updater === "function"
163
+ ? updater(rowSelectionRef.current)
164
+ : updater;
165
+ setRowSelection(next);
166
+ externalOnRowSelectionChange?.(next);
167
+ },
168
+ [externalOnRowSelectionChange],
169
+ );
170
+
171
+ // Column-state setters double as tanstack `OnChangeFn`s - React's
172
+ // `SetStateAction<T>` is structurally identical to tanstack's `Updater<T>`,
173
+ // so a no-op forwarding wrapper would just add allocations.
174
+
175
+ // ── Filter handler ──────────────────────────────────────────────────────────
176
+ const setFilter = useCallback(
177
+ (key: string, value: string) => {
178
+ const next = { ...filterValuesRef.current, [key]: value };
179
+ setFilterValues(next);
180
+ onFilterChange?.(key, value, next);
181
+
182
+ const p = paginationRef.current;
183
+ if (p.pageIndex !== 0) {
184
+ handlePaginationChange({ ...p, pageIndex: 0 });
185
+ }
186
+ },
187
+ [onFilterChange, handlePaginationChange],
188
+ );
189
+
190
+ const setFilters = useCallback(
191
+ (values: Record<string, string>) => {
192
+ const next = { ...filterValuesRef.current, ...values };
193
+ setFilterValues(next);
194
+ for (const [k, v] of Object.entries(values)) {
195
+ onFilterChange?.(k, v, next);
196
+ }
197
+
198
+ const p = paginationRef.current;
199
+ if (p.pageIndex !== 0) {
200
+ handlePaginationChange({ ...p, pageIndex: 0 });
201
+ }
202
+ },
203
+ [onFilterChange, handlePaginationChange],
204
+ );
205
+
206
+ const resetFilters = useCallback(() => {
207
+ setFilterValues(defaultFilterValues);
208
+
209
+ const p = paginationRef.current;
210
+ if (p.pageIndex !== 0) {
211
+ handlePaginationChange({ ...p, pageIndex: 0 });
212
+ }
213
+ }, [defaultFilterValues, handlePaginationChange]);
214
+
215
+ // ── Inline editing actions ──────────────────────────────────────────────────
216
+ const startEdit = useCallback((rowId: string) => {
217
+ const row = localDataRef.current.find((r) => r.id === rowId);
218
+ snapshotRef.current = row ? { ...row } : null;
219
+ setEditingRowId(rowId);
220
+ }, []);
221
+
222
+ const cancelEdit = useCallback(() => {
223
+ const snapshot = snapshotRef.current;
224
+ if (snapshot) {
225
+ setLocalData((prev) =>
226
+ prev.map((r) => (r.id === snapshot.id ? snapshot : r)),
227
+ );
228
+ }
229
+ snapshotRef.current = null;
230
+ setEditingRowId(null);
231
+ }, []);
232
+
233
+ const confirmSave = useCallback(() => {
234
+ snapshotRef.current = null;
235
+ setEditingRowId(null);
236
+ }, []);
237
+
238
+ // Compute `next` from the latest-data ref and fire `onDataChange` outside
239
+ // the setState updater - see the latest-value-ref note above. The ref is
240
+ // advanced synchronously so back-to-back calls in the same tick compose
241
+ // correctly instead of clobbering each other.
242
+ const updateRowData = useCallback(
243
+ (rowId: string, columnId: string, value: unknown) => {
244
+ const next = localDataRef.current.map((r) =>
245
+ r.id === rowId ? ({ ...r, [columnId]: value } as TData) : r,
246
+ );
247
+ localDataRef.current = next;
248
+ setLocalData(next);
249
+ onDataChange?.(next);
250
+ },
251
+ [onDataChange],
252
+ );
253
+
254
+ const reorderRow = useCallback(
255
+ (draggedRow: TData, targetRow: TData) => {
256
+ const prev = localDataRef.current;
257
+ const draggedIdx = prev.indexOf(draggedRow);
258
+ const targetIdx = prev.indexOf(targetRow);
259
+ if (draggedIdx === -1 || targetIdx === -1 || draggedIdx === targetIdx) {
260
+ return;
261
+ }
262
+ const next = [...prev];
263
+ const [moved] = next.splice(draggedIdx, 1);
264
+ next.splice(targetIdx, 0, moved);
265
+ localDataRef.current = next;
266
+ setLocalData(next);
267
+ onDataChange?.(next);
268
+ },
269
+ [onDataChange],
270
+ );
271
+
272
+ // ── Page helpers ────────────────────────────────────────────────────────────
273
+ const goToPage = useCallback(
274
+ (index: number) =>
275
+ handlePaginationChange({ ...pagination, pageIndex: index }),
276
+ [handlePaginationChange, pagination],
277
+ );
278
+
279
+ const setPageSize = useCallback(
280
+ (size: number) => handlePaginationChange({ pageIndex: 0, pageSize: size }),
281
+ [handlePaginationChange],
282
+ );
283
+
284
+ const clearSelection = useCallback(() => {
285
+ setRowSelection({});
286
+ }, []);
287
+
288
+ // ── Column-state imperative helpers ────────────────────────────────────────
289
+ const toggleColumnVisibility = useCallback((columnId: string) => {
290
+ setColumnVisibilityState((prev) => ({
291
+ ...prev,
292
+ [columnId]: prev[columnId] === false,
293
+ }));
294
+ }, []);
295
+
296
+ // React's useState setters are already stable - no useCallback wrapper needed.
297
+ const setColumnVisibility = setColumnVisibilityState;
298
+ const setColumnOrder = setColumnOrderState;
299
+ const setColumnPinning = setColumnPinningState;
300
+ const setColumnSizing = setColumnSizingState;
301
+ const setGrouping = setGroupingState;
302
+ const setExpanded = setExpandedState;
303
+
304
+ const toggleGrouping = useCallback((columnId: string) => {
305
+ setGroupingState((prev) =>
306
+ prev.includes(columnId)
307
+ ? prev.filter((id) => id !== columnId)
308
+ : [...prev, columnId],
309
+ );
310
+ }, []);
311
+
312
+ // ── State persistence (URL / localStorage / manual) ─────────────────────────
313
+ // Lives here (not in <DataTable>) so consumers get a single hook that wires
314
+ // their state to the chosen destination. The persisted blob shape is mode-
315
+ // agnostic - switching modes preserves the same data.
316
+ usePersist({
317
+ config: persistState,
318
+ filterKeys: Object.keys(defaultFilterValues),
319
+ filterValues,
320
+ setFilterValues,
321
+ pagination,
322
+ setPagination,
323
+ sorting,
324
+ setSorting,
325
+ columnOrder,
326
+ setColumnOrder: setColumnOrderState,
327
+ columnVisibility,
328
+ setColumnVisibility: setColumnVisibilityState,
329
+ columnPinning,
330
+ setColumnPinning: setColumnPinningState,
331
+ columnSizing,
332
+ setColumnSizing: setColumnSizingState,
333
+ grouping,
334
+ setGrouping: setGroupingState,
335
+ expanded,
336
+ setExpanded: setExpandedState,
337
+ });
338
+
339
+ // ── Derived data ────────────────────────────────────────────────────────────
340
+ const filteredData = useMemo<TData[]>(() => {
341
+ if (!filterFn) return localData;
342
+ return filterFn(localData, filterValues);
343
+ }, [localData, filterFn, filterValues]);
344
+
345
+ const pageData = useMemo<TData[]>(() => {
346
+ if (!clientSidePagination) return filteredData;
347
+ const { pageIndex, pageSize } = pagination;
348
+ return filteredData.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize);
349
+ }, [filteredData, pagination, clientSidePagination]);
350
+
351
+ const totalCount = externalTotalCount ?? filteredData.length;
352
+ const totalPages =
353
+ externalTotalPages ?? Math.ceil(filteredData.length / pagination.pageSize);
354
+
355
+ const selectedIds = useMemo(() => Object.keys(rowSelection), [rowSelection]);
356
+ const selectedRows = useMemo(
357
+ () => localData.filter((r) => rowSelection[r.id]),
358
+ [localData, rowSelection],
359
+ );
360
+
361
+ // ── Assemble DataTable props ─────────────────────────────────────────────────
362
+ const props: Omit<
363
+ DataTableProps<TData>,
364
+ "columns" | "filters" | "className"
365
+ > = useMemo(
366
+ () => ({
367
+ data: pageData,
368
+ sorting,
369
+ onSortingChange: handleSortingChange,
370
+ manualSorting,
371
+ filterValues,
372
+ onFilterChange: setFilter,
373
+ showPagination: true,
374
+ pagination,
375
+ onPaginationChange: handlePaginationChange,
376
+ totalPages,
377
+ totalCount,
378
+ rowSelection,
379
+ onRowSelectionChange: handleRowSelectionChange,
380
+ editingRowId,
381
+ onUpdateData: updateRowData,
382
+ columnOrder,
383
+ onColumnOrderChange: setColumnOrderState,
384
+ columnVisibility,
385
+ onColumnVisibilityChange: setColumnVisibilityState,
386
+ columnPinning,
387
+ onColumnPinningChange: setColumnPinningState,
388
+ defaultColumnPinning,
389
+ columnSizing,
390
+ onColumnSizingChange: setColumnSizingState,
391
+ grouping,
392
+ onGroupingChange: setGroupingState,
393
+ expanded,
394
+ onExpandedChange: setExpandedState,
395
+ onRowOrderChange: reorderRow,
396
+ }),
397
+ [
398
+ pageData,
399
+ sorting,
400
+ handleSortingChange,
401
+ manualSorting,
402
+ filterValues,
403
+ setFilter,
404
+ pagination,
405
+ handlePaginationChange,
406
+ totalPages,
407
+ totalCount,
408
+ rowSelection,
409
+ handleRowSelectionChange,
410
+ editingRowId,
411
+ updateRowData,
412
+ columnOrder,
413
+ columnVisibility,
414
+ columnPinning,
415
+ defaultColumnPinning,
416
+ columnSizing,
417
+ grouping,
418
+ expanded,
419
+ reorderRow,
420
+ ],
421
+ );
422
+
423
+ const actions = useMemo(
424
+ () => ({
425
+ setFilter,
426
+ setFilters,
427
+ resetFilters,
428
+ goToPage,
429
+ setPageSize,
430
+ clearSelection,
431
+ startEdit,
432
+ cancelEdit,
433
+ confirmSave,
434
+ updateRowData,
435
+ reorderRow,
436
+ toggleColumnVisibility,
437
+ setColumnVisibility,
438
+ setColumnOrder,
439
+ setColumnPinning,
440
+ setColumnSizing,
441
+ toggleGrouping,
442
+ setGrouping,
443
+ setExpanded,
444
+ }),
445
+ [
446
+ setFilter,
447
+ setFilters,
448
+ resetFilters,
449
+ goToPage,
450
+ setPageSize,
451
+ clearSelection,
452
+ startEdit,
453
+ cancelEdit,
454
+ confirmSave,
455
+ updateRowData,
456
+ reorderRow,
457
+ toggleColumnVisibility,
458
+ setColumnVisibility,
459
+ setColumnOrder,
460
+ setColumnPinning,
461
+ setColumnSizing,
462
+ toggleGrouping,
463
+ setGrouping,
464
+ setExpanded,
465
+ ],
466
+ );
467
+
468
+ return {
469
+ props,
470
+ state: {
471
+ filterValues,
472
+ pagination,
473
+ sorting,
474
+ rowSelection,
475
+ editingRowId,
476
+ columnOrder,
477
+ columnVisibility,
478
+ columnPinning,
479
+ columnSizing,
480
+ grouping,
481
+ expanded,
482
+ pageData,
483
+ filteredData,
484
+ selectedIds,
485
+ selectedRows,
486
+ },
487
+ actions,
488
+ };
489
+ }
490
+
491
+ // ─── Persist wiring ──────────────────────────────────────────────────────────
492
+ // Pure read/write/snapshot/apply helpers live in `utils.ts`. This hook just
493
+ // orchestrates them - hydrate once on mount, then sync state → destination
494
+ // whenever any persistable slice changes.
495
+
496
+ function usePersist(args: PersistArgs): void {
497
+ const enabled = args.config?.enabled ?? false;
498
+ const mode = args.config?.mode;
499
+
500
+ const hydrated = useRef(false);
501
+ // pageSize at first render - used by search-params mode to keep the URL
502
+ // clean (only writes ?pageSize=X when it differs from this default).
503
+ const defaultPageSizeRef = useRef(args.pagination.pageSize);
504
+
505
+ // Stable ref for args/config to avoid re-running effects when consumers
506
+ // pass fresh closures every render.
507
+ const argsRef = useRef(args);
508
+ useEffect(() => {
509
+ argsRef.current = args;
510
+ });
511
+
512
+ // ── Hydrate once on mount ──────────────────────────────────────────────────
513
+ useEffect(() => {
514
+ if (!enabled || hydrated.current) return;
515
+ hydrated.current = true;
516
+
517
+ const cfg = argsRef.current.config;
518
+ if (!cfg) return;
519
+
520
+ let initial: PersistedState = {};
521
+ if (cfg.mode === "search-params") {
522
+ initial = readSearchParams(
523
+ argsRef.current.filterKeys,
524
+ cfg.paramMap ?? {},
525
+ defaultPageSizeRef.current,
526
+ );
527
+ } else if (cfg.mode === "local-storage") {
528
+ initial = readLocalStorage(cfg.storageKey);
529
+ } else if (cfg.mode === "manual") {
530
+ initial = cfg.initialState ?? {};
531
+ }
532
+
533
+ applyPersistedState(initial, argsRef.current);
534
+ }, [enabled, mode]);
535
+
536
+ // ── Sync state → destination on every change ──────────────────────────────
537
+ useEffect(() => {
538
+ if (!enabled || !hydrated.current) return;
539
+ const cfg = argsRef.current.config;
540
+ if (!cfg) return;
541
+
542
+ const snapshot = snapshotPersistedState({
543
+ ...argsRef.current,
544
+ defaultPageSize: defaultPageSizeRef.current,
545
+ });
546
+
547
+ if (cfg.mode === "search-params") {
548
+ writeSearchParams(
549
+ snapshot,
550
+ argsRef.current.filterKeys,
551
+ cfg.paramMap ?? {},
552
+ defaultPageSizeRef.current,
553
+ );
554
+ } else if (cfg.mode === "local-storage") {
555
+ writeLocalStorage(cfg.storageKey, snapshot);
556
+ }
557
+ // Fired for every mode (manual mode's primary side-effect *is* this call;
558
+ // for URL/storage modes it doubles as a subscribe-to-change hook).
559
+ cfg.onChange?.(snapshot);
560
+ }, [
561
+ enabled,
562
+ mode,
563
+ args.filterValues,
564
+ args.pagination,
565
+ args.sorting,
566
+ args.columnOrder,
567
+ args.columnVisibility,
568
+ args.columnPinning,
569
+ args.columnSizing,
570
+ args.grouping,
571
+ args.expanded,
572
+ ]);
573
+ }