@djangocfg/widget-data 0.1.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 (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +55 -0
  3. package/package.json +122 -0
  4. package/src/DataGrid/README.md +48 -0
  5. package/src/DataGrid/context/DataGridContext.tsx +442 -0
  6. package/src/DataGrid/context/index.ts +4 -0
  7. package/src/DataGrid/index.ts +34 -0
  8. package/src/DataGrid/lazy.tsx +23 -0
  9. package/src/DataGrid/parts/DataGrid.tsx +125 -0
  10. package/src/DataGrid/parts/DataGridCell.tsx +30 -0
  11. package/src/DataGrid/parts/DataGridHeader.tsx +111 -0
  12. package/src/DataGrid/parts/DataGridPagination.tsx +105 -0
  13. package/src/DataGrid/parts/DataGridRow.tsx +84 -0
  14. package/src/DataGrid/parts/index.ts +7 -0
  15. package/src/DataGrid/types.ts +95 -0
  16. package/src/DataTable/README.md +42 -0
  17. package/src/DataTable/context/DataTableContext.tsx +434 -0
  18. package/src/DataTable/context/index.ts +4 -0
  19. package/src/DataTable/index.ts +33 -0
  20. package/src/DataTable/lazy.tsx +23 -0
  21. package/src/DataTable/parts/DataTable.tsx +142 -0
  22. package/src/DataTable/parts/DataTableHeader.tsx +103 -0
  23. package/src/DataTable/parts/DataTablePagination.tsx +115 -0
  24. package/src/DataTable/parts/DataTableRow.tsx +96 -0
  25. package/src/DataTable/parts/index.ts +6 -0
  26. package/src/DataTable/types.ts +88 -0
  27. package/src/FileIcon/FileIcon.tsx +87 -0
  28. package/src/FileIcon/get-file-icon.ts +73 -0
  29. package/src/FileIcon/icons/icon-data.ts +399 -0
  30. package/src/FileIcon/index.ts +19 -0
  31. package/src/FileIcon/loader.ts +29 -0
  32. package/src/FileIcon/specialFolders.ts +110 -0
  33. package/src/FileIcon/treeAdapter.tsx +49 -0
  34. package/src/JsonTree/JsonViewer.tsx +720 -0
  35. package/src/JsonTree/README.md +142 -0
  36. package/src/JsonTree/index.tsx +11 -0
  37. package/src/JsonTree/lazy.tsx +22 -0
  38. package/src/JsonTree/types.ts +91 -0
  39. package/src/Kanban/context/KanbanContext.tsx +165 -0
  40. package/src/Kanban/context/index.ts +4 -0
  41. package/src/Kanban/index.ts +31 -0
  42. package/src/Kanban/lazy.tsx +23 -0
  43. package/src/Kanban/parts/Kanban.tsx +77 -0
  44. package/src/Kanban/parts/KanbanCard.tsx +76 -0
  45. package/src/Kanban/parts/KanbanColumn.tsx +79 -0
  46. package/src/Kanban/parts/KanbanDragOverlay.tsx +46 -0
  47. package/src/Kanban/parts/index.ts +6 -0
  48. package/src/Kanban/types.ts +45 -0
  49. package/src/Listbox/context/ListboxProvider.tsx +616 -0
  50. package/src/Listbox/context/index.ts +8 -0
  51. package/src/Listbox/index.ts +28 -0
  52. package/src/Listbox/lazy.tsx +31 -0
  53. package/src/Listbox/parts/ListboxGroup.tsx +71 -0
  54. package/src/Listbox/parts/ListboxItem.tsx +151 -0
  55. package/src/Listbox/parts/index.ts +12 -0
  56. package/src/Listbox/types.ts +98 -0
  57. package/src/Masonry/README.md +79 -0
  58. package/src/Masonry/context/MasonryProvider.tsx +1205 -0
  59. package/src/Masonry/context/index.ts +8 -0
  60. package/src/Masonry/index.ts +18 -0
  61. package/src/Masonry/lazy.tsx +20 -0
  62. package/src/Masonry/parts/index.ts +2 -0
  63. package/src/Masonry/types.ts +44 -0
  64. package/src/Timeline/context/TimelineProvider.tsx +474 -0
  65. package/src/Timeline/context/index.ts +16 -0
  66. package/src/Timeline/index.ts +29 -0
  67. package/src/Timeline/lazy.tsx +27 -0
  68. package/src/Timeline/parts/index.ts +2 -0
  69. package/src/Timeline/types.ts +25 -0
  70. package/src/Tree/FinderTree.tsx +42 -0
  71. package/src/Tree/README.md +542 -0
  72. package/src/Tree/TreeDndProvider.tsx +137 -0
  73. package/src/Tree/TreeRoot.tsx +290 -0
  74. package/src/Tree/components/TreeChevron.tsx +48 -0
  75. package/src/Tree/components/TreeContent.tsx +70 -0
  76. package/src/Tree/components/TreeDropIndicator.tsx +65 -0
  77. package/src/Tree/components/TreeEmpty.tsx +22 -0
  78. package/src/Tree/components/TreeEmptyArea.tsx +160 -0
  79. package/src/Tree/components/TreeError.tsx +24 -0
  80. package/src/Tree/components/TreeIcon.tsx +39 -0
  81. package/src/Tree/components/TreeIndentGuides.tsx +33 -0
  82. package/src/Tree/components/TreeLabel.tsx +39 -0
  83. package/src/Tree/components/TreeRenameInput.tsx +114 -0
  84. package/src/Tree/components/TreeRow.tsx +300 -0
  85. package/src/Tree/components/TreeSearchInput.tsx +50 -0
  86. package/src/Tree/components/TreeSkeleton.tsx +22 -0
  87. package/src/Tree/components/index.ts +28 -0
  88. package/src/Tree/context/TreeContext.tsx +409 -0
  89. package/src/Tree/context/TreeContextValue.ts +139 -0
  90. package/src/Tree/context/async-children/collect-ids.ts +27 -0
  91. package/src/Tree/context/async-children/index.ts +8 -0
  92. package/src/Tree/context/async-children/use-async-children.ts +157 -0
  93. package/src/Tree/context/clipboard/index.ts +4 -0
  94. package/src/Tree/context/clipboard/use-clipboard.ts +115 -0
  95. package/src/Tree/context/dnd/index.ts +8 -0
  96. package/src/Tree/context/dnd/use-dnd.ts +194 -0
  97. package/src/Tree/context/expansion/index.ts +4 -0
  98. package/src/Tree/context/expansion/use-expansion.ts +55 -0
  99. package/src/Tree/context/hooks.ts +177 -0
  100. package/src/Tree/context/index.ts +16 -0
  101. package/src/Tree/context/menu/builtin-actions.ts +358 -0
  102. package/src/Tree/context/menu/index.ts +11 -0
  103. package/src/Tree/context/menu/render.tsx +104 -0
  104. package/src/Tree/context/menu/use-resolved-menu.ts +141 -0
  105. package/src/Tree/context/persist/index.ts +4 -0
  106. package/src/Tree/context/persist/use-persist-sync.ts +74 -0
  107. package/src/Tree/context/rename/index.ts +4 -0
  108. package/src/Tree/context/rename/use-rename.ts +113 -0
  109. package/src/Tree/context/selection/index.ts +4 -0
  110. package/src/Tree/context/selection/use-selection.ts +146 -0
  111. package/src/Tree/context/state/index.ts +6 -0
  112. package/src/Tree/context/state/initial.ts +41 -0
  113. package/src/Tree/context/state/reducer.ts +76 -0
  114. package/src/Tree/context/state/types.ts +46 -0
  115. package/src/Tree/data/appearance.ts +237 -0
  116. package/src/Tree/data/childCache.ts +43 -0
  117. package/src/Tree/data/clipboard.ts +33 -0
  118. package/src/Tree/data/createDemoTree.ts +42 -0
  119. package/src/Tree/data/dnd.ts +123 -0
  120. package/src/Tree/data/finderShortcuts.ts +67 -0
  121. package/src/Tree/data/flatten.ts +66 -0
  122. package/src/Tree/data/index.ts +45 -0
  123. package/src/Tree/data/persist.ts +62 -0
  124. package/src/Tree/data/renameUtils.ts +51 -0
  125. package/src/Tree/data/selection.ts +161 -0
  126. package/src/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
  127. package/src/Tree/hooks/finder-hotkeys/index.ts +8 -0
  128. package/src/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
  129. package/src/Tree/hooks/index.ts +25 -0
  130. package/src/Tree/hooks/keyboard/activation.ts +27 -0
  131. package/src/Tree/hooks/keyboard/arrow-nav.ts +26 -0
  132. package/src/Tree/hooks/keyboard/expand-collapse.ts +54 -0
  133. package/src/Tree/hooks/keyboard/index.ts +10 -0
  134. package/src/Tree/hooks/keyboard/types.ts +39 -0
  135. package/src/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
  136. package/src/Tree/hooks/type-ahead/index.ts +5 -0
  137. package/src/Tree/hooks/type-ahead/match-prefix.ts +42 -0
  138. package/src/Tree/hooks/type-ahead/use-tree-type-ahead.ts +89 -0
  139. package/src/Tree/index.tsx +135 -0
  140. package/src/Tree/lazy.tsx +14 -0
  141. package/src/Tree/types/activation.ts +30 -0
  142. package/src/Tree/types/adapter.ts +70 -0
  143. package/src/Tree/types/index.ts +27 -0
  144. package/src/Tree/types/labels.ts +97 -0
  145. package/src/Tree/types/loader.ts +9 -0
  146. package/src/Tree/types/node.ts +38 -0
  147. package/src/Tree/types/root-props.ts +158 -0
  148. package/src/Tree/types/selection.ts +3 -0
  149. package/src/Tree/types/slots.ts +64 -0
  150. package/src/index.ts +21 -0
  151. package/src/stories/_fixtures/categories.ts +51 -0
  152. package/src/stories/_fixtures/finder-store.ts +196 -0
  153. package/src/stories/_fixtures/fs.ts +83 -0
  154. package/src/stories/_fixtures/index.ts +5 -0
  155. package/src/stories/_fixtures/org.ts +46 -0
  156. package/src/stories/_fixtures/status.ts +21 -0
  157. package/src/styles/index.css +10 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Reforms.ai
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,55 @@
1
+ # @djangocfg/widget-data
2
+
3
+ The surfaces that put rows, nodes and cards on screen: a file tree, a JSON
4
+ viewer, a data grid and table, a kanban, a listbox, a masonry and a timeline.
5
+
6
+ ## Install
7
+
8
+ ```bash
9
+ pnpm add @djangocfg/widget-data @djangocfg/ui-core
10
+ ```
11
+
12
+ ```tsx
13
+ import { FinderTree } from '@djangocfg/widget-data/tree';
14
+ import '@djangocfg/widget-data/styles';
15
+ ```
16
+
17
+ The styles import is not optional: Tailwind v4 does not scan `node_modules`,
18
+ so without it every surface here renders unstyled.
19
+
20
+ ## Subpaths
21
+
22
+ | Subpath | What | Own docs |
23
+ |---|---|---|
24
+ | `./tree` | `TreeRoot`, `FinderTree` — generic, slot-driven, async, with Finder CRUD | [`src/Tree/README.md`](src/Tree/README.md) |
25
+ | `./file-icon` | `FileIcon`, `getFileIcon`, `createFileIconSlot` | — |
26
+ | `./json-tree` | `JsonTree` — collapsible JSON with search and copy | [`src/JsonTree/README.md`](src/JsonTree/README.md) |
27
+ | `./data-grid` | `DataGrid` — sorting, filtering, pagination, selection | [`src/DataGrid/README.md`](src/DataGrid/README.md) |
28
+ | `./data-table` | `DataTable` — the same, as a semantic `<table>` | [`src/DataTable/README.md`](src/DataTable/README.md) |
29
+ | `./kanban` | `Kanban` — columns and cards, drag included | — |
30
+ | `./listbox` | `Listbox` — a composable selection primitive | — |
31
+ | `./masonry` | `Masonry` — a column layout | [`src/Masonry/README.md`](src/Masonry/README.md) |
32
+ | `./timeline` | `Timeline` — an ordered event column | — |
33
+
34
+ Each subpath also exports its `Lazy*` wrapper (`LazyTree`, `LazyDataGrid`, …)
35
+ for a surface that should not be in the entry chunk.
36
+
37
+ **Prefer a subpath to the root.** The root barrel exists and works, but pulls
38
+ all nine trees; an app that wants a tree should ask for the tree.
39
+
40
+ ## Grouping
41
+
42
+ One package rather than nine, because these share a shape rather than a
43
+ library: none of them carries a heavy dependency the others would pay for. The
44
+ whole external surface is `@dnd-kit/core` (Tree only), three small Radix and
45
+ `cva` utilities, and `lucide-react`.
46
+
47
+ `FileIcon` lives here rather than with the visual widgets because it is a Tree
48
+ slot — `createFileIconSlot` types against `TreeRowSlot`, and separating them
49
+ would put a package boundary through one component's two halves.
50
+
51
+ ## Verify
52
+
53
+ ```bash
54
+ pnpm --filter @djangocfg/widget-data check-types test
55
+ ```
package/package.json ADDED
@@ -0,0 +1,122 @@
1
+ {
2
+ "name": "@djangocfg/widget-data",
3
+ "version": "0.1.1",
4
+ "description": "Data surfaces: file tree, JSON viewer, data grid and table, kanban, listbox, masonry and timeline",
5
+ "keywords": [
6
+ "tree",
7
+ "data-grid",
8
+ "data-table",
9
+ "kanban",
10
+ "json-viewer",
11
+ "timeline",
12
+ "react",
13
+ "typescript"
14
+ ],
15
+ "author": {
16
+ "name": "DjangoCFG",
17
+ "url": "https://djangocfg.com"
18
+ },
19
+ "homepage": "https://djangocfg.com",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "https://github.com/markolofsen/django-cfg.git",
23
+ "directory": "widgets/data"
24
+ },
25
+ "license": "MIT",
26
+ "type": "module",
27
+ "main": "./src/index.ts",
28
+ "types": "./src/index.ts",
29
+ "exports": {
30
+ ".": {
31
+ "types": "./src/index.ts",
32
+ "default": "./src/index.ts"
33
+ },
34
+ "./tree": {
35
+ "types": "./src/Tree/index.tsx",
36
+ "default": "./src/Tree/index.tsx"
37
+ },
38
+ "./file-icon": {
39
+ "types": "./src/FileIcon/index.ts",
40
+ "default": "./src/FileIcon/index.ts"
41
+ },
42
+ "./file-icon/get-file-icon": {
43
+ "types": "./src/FileIcon/get-file-icon.ts",
44
+ "default": "./src/FileIcon/get-file-icon.ts"
45
+ },
46
+ "./json-tree": {
47
+ "types": "./src/JsonTree/index.tsx",
48
+ "default": "./src/JsonTree/index.tsx"
49
+ },
50
+ "./data-grid": {
51
+ "types": "./src/DataGrid/index.ts",
52
+ "default": "./src/DataGrid/index.ts"
53
+ },
54
+ "./data-table": {
55
+ "types": "./src/DataTable/index.ts",
56
+ "default": "./src/DataTable/index.ts"
57
+ },
58
+ "./kanban": {
59
+ "types": "./src/Kanban/index.ts",
60
+ "default": "./src/Kanban/index.ts"
61
+ },
62
+ "./listbox": {
63
+ "types": "./src/Listbox/index.ts",
64
+ "default": "./src/Listbox/index.ts"
65
+ },
66
+ "./masonry": {
67
+ "types": "./src/Masonry/index.ts",
68
+ "default": "./src/Masonry/index.ts"
69
+ },
70
+ "./timeline": {
71
+ "types": "./src/Timeline/index.ts",
72
+ "default": "./src/Timeline/index.ts"
73
+ },
74
+ "./styles": "./src/styles/index.css"
75
+ },
76
+ "files": [
77
+ "src",
78
+ "!src/**/*.test.ts",
79
+ "!src/**/*.test.tsx",
80
+ "!src/**/__tests__",
81
+ "!src/**/*.stories.tsx",
82
+ "README.md",
83
+ "LICENSE"
84
+ ],
85
+ "scripts": {
86
+ "check": "tsc --noEmit",
87
+ "check-types": "tsc --noEmit",
88
+ "test": "vitest run",
89
+ "test:watch": "vitest"
90
+ },
91
+ "dependencies": {
92
+ "@djangocfg/widget-kit": "^0.1.1",
93
+ "@dnd-kit/core": "^6.3.1",
94
+ "@radix-ui/react-direction": "^1.1.2",
95
+ "@radix-ui/react-slot": "^1.3.0",
96
+ "class-variance-authority": "^0.7.1"
97
+ },
98
+ "peerDependencies": {
99
+ "@djangocfg/ui-core": "^2.1.542",
100
+ "lucide-react": "^0.545.0",
101
+ "react": "^19.0.0",
102
+ "react-dom": "^19.0.0"
103
+ },
104
+ "devDependencies": {
105
+ "@djangocfg/typescript-config": "^2.1.542",
106
+ "@djangocfg/ui-core": "^2.1.542",
107
+ "@storybook/react-vite": "^10.5.0",
108
+ "@types/node": "^25.9.5",
109
+ "@types/react": "19.2.15",
110
+ "@types/react-dom": "19.2.3",
111
+ "jsdom": "^29.1.1",
112
+ "lucide-react": "0.545.0",
113
+ "react": "19.2.7",
114
+ "react-dom": "19.2.7",
115
+ "storybook": "^10.5.0",
116
+ "typescript": "^5.9.3",
117
+ "vitest": "^4.1.10"
118
+ },
119
+ "publishConfig": {
120
+ "access": "public"
121
+ }
122
+ }
@@ -0,0 +1,48 @@
1
+ # DataGrid
2
+
3
+ Headless data grid with built-in sort / filter / pagination / selection and column resizing. Composable: `<DataGrid>` is the all-in-one entry point, while `DataGridProvider` + `DataGridHeader` / `DataGridRow` / `DataGridCell` / `DataGridPagination` let you swap individual parts. Pure React, no `@tanstack/react-table` dependency.
4
+
5
+ ```tsx
6
+ import { DataGrid } from '@djangocfg/ui-tools/data-grid';
7
+
8
+ <DataGrid
9
+ data={users}
10
+ columns={[
11
+ { key: 'name', header: 'Name', sortable: true, filterable: true },
12
+ { key: 'email', header: 'Email', sortable: true },
13
+ { key: 'role', header: 'Role', align: 'right' },
14
+ ]}
15
+ getRowId={(u) => u.id}
16
+ selectionMode="multiple"
17
+ onSelectionChange={setSelected}
18
+ />
19
+ ```
20
+
21
+ ## Props
22
+
23
+ | Prop | Type | Default | Description |
24
+ |---|---|---|---|
25
+ | `data` | `T[]` | — | Row data. |
26
+ | `columns` | `DataGridColumn<T>[]` | — | Column defs (`key`, `header`, `cell?`, `sortable?`, `filterable?`, `width?`, `align?`, `filterFn?`). |
27
+ | `getRowId` | `(row: T) => string \| number` | — | Stable row id. |
28
+ | `selectionMode` | `'none' \| 'single' \| 'multiple'` | `'none'` | Selection behaviour. |
29
+ | `initialSelectedIds` / `onSelectionChange` | `RowId[]` / `(ids) => void` | — | Uncontrolled selection. |
30
+ | `sort` / `onSortChange` / `initialSort` | `DataGridSortState` | — | Controlled or uncontrolled sort. |
31
+ | `filters` / `onFilterChange` / `initialFilters` | `DataGridFilterState` | — | Per-column text filters. |
32
+ | `pagination` / `onPaginationChange` / `initialPagination` | `DataGridPaginationState` | — | Controlled or uncontrolled pagination. |
33
+ | `pageSizeOptions` | `number[]` | `[10, 25, 50, 100]` | Page-size choices. |
34
+ | `resizable` | `boolean` | `false` | Enable column resizing. |
35
+ | `stickyHeader` | `boolean` | `true` | Sticky header row. |
36
+ | `loading` | `boolean` | `false` | Loading state. |
37
+ | `emptyMessage` | `ReactNode` | — | Rendered when `data` is empty. |
38
+ | `getRowClassName` | `(row, i) => string` | — | Per-row class hook. |
39
+ | `onRowClick` / `onRowDoubleClick` | `(row) => void` | — | Row event handlers. |
40
+
41
+ ## Notes
42
+
43
+ - No virtualization — for >10k rows pair with pagination or use a virtualized list above the grid.
44
+ - All state slots support both controlled (`sort` + `onSortChange`) and uncontrolled (`initialSort`) usage.
45
+
46
+ ---
47
+
48
+ Adapted from jalcoui (MIT).
@@ -0,0 +1,442 @@
1
+ 'use client';
2
+
3
+ import * as React from 'react';
4
+ import {
5
+ createContext,
6
+ useCallback,
7
+ useEffect,
8
+ useMemo,
9
+ useReducer,
10
+ useRef,
11
+ } from 'react';
12
+
13
+ import type {
14
+ DataGridColumn,
15
+ DataGridFilterState,
16
+ DataGridPaginationState,
17
+ DataGridProps,
18
+ DataGridRowId,
19
+ DataGridSelectionMode,
20
+ DataGridSortState,
21
+ SortDirection,
22
+ } from '../types';
23
+
24
+ // =====================================================================
25
+ // Reducer
26
+ // =====================================================================
27
+
28
+ interface State<T> {
29
+ sort: DataGridSortState;
30
+ filters: DataGridFilterState;
31
+ pagination: DataGridPaginationState;
32
+ selected: Set<DataGridRowId>;
33
+ }
34
+
35
+ type Action<T> =
36
+ | { type: 'set-sort'; sort: DataGridSortState }
37
+ | { type: 'set-filter'; key: string; value: string }
38
+ | { type: 'clear-filters' }
39
+ | { type: 'set-page'; page: number }
40
+ | { type: 'set-page-size'; pageSize: number }
41
+ | { type: 'select'; id: DataGridRowId; mode: DataGridSelectionMode }
42
+ | { type: 'select-many'; ids: DataGridRowId[] }
43
+ | { type: 'clear-selection' }
44
+ | { type: 'select-all'; ids: DataGridRowId[] }
45
+ | { type: 'reset-pagination' };
46
+
47
+ const reducer = <T,>(state: State<T>, action: Action<T>): State<T> => {
48
+ switch (action.type) {
49
+ case 'set-sort':
50
+ return { ...state, sort: action.sort };
51
+ case 'set-filter': {
52
+ const next = { ...state.filters, [action.key]: action.value };
53
+ if (action.value === '') delete next[action.key];
54
+ return { ...state, filters: next, pagination: { ...state.pagination, page: 1 } };
55
+ }
56
+ case 'clear-filters':
57
+ return { ...state, filters: {}, pagination: { ...state.pagination, page: 1 } };
58
+ case 'set-page':
59
+ return { ...state, pagination: { ...state.pagination, page: action.page } };
60
+ case 'set-page-size':
61
+ return {
62
+ ...state,
63
+ pagination: { page: 1, pageSize: action.pageSize },
64
+ };
65
+ case 'select': {
66
+ if (action.mode === 'none') return state;
67
+ if (action.mode === 'single') {
68
+ return { ...state, selected: new Set([action.id]) };
69
+ }
70
+ const next = new Set(state.selected);
71
+ if (next.has(action.id)) next.delete(action.id);
72
+ else next.add(action.id);
73
+ return { ...state, selected: next };
74
+ }
75
+ case 'select-many':
76
+ return { ...state, selected: new Set(action.ids) };
77
+ case 'clear-selection':
78
+ return { ...state, selected: new Set() };
79
+ case 'select-all':
80
+ return { ...state, selected: new Set(action.ids) };
81
+ case 'reset-pagination':
82
+ return { ...state, pagination: { ...state.pagination, page: 1 } };
83
+ default:
84
+ return state;
85
+ }
86
+ };
87
+
88
+ // =====================================================================
89
+ // Context value
90
+ // =====================================================================
91
+
92
+ export interface DataGridContextValue<T = unknown> {
93
+ // State
94
+ sort: DataGridSortState;
95
+ filters: DataGridFilterState;
96
+ pagination: DataGridPaginationState;
97
+ selected: ReadonlySet<DataGridRowId>;
98
+
99
+ // Processed data
100
+ rows: T[];
101
+ totalRows: number;
102
+ pageCount: number;
103
+
104
+ // Actions
105
+ setSort: (key: string) => void;
106
+ setFilter: (key: string, value: string) => void;
107
+ clearFilters: () => void;
108
+ setPage: (page: number) => void;
109
+ setPageSize: (pageSize: number) => void;
110
+ select: (id: DataGridRowId) => void;
111
+ selectMany: (ids: DataGridRowId[]) => void;
112
+ clearSelection: () => void;
113
+ selectAllVisible: () => void;
114
+ isSelected: (id: DataGridRowId) => boolean;
115
+
116
+ // Config
117
+ columns: DataGridColumn<T>[];
118
+ getRowId: (row: T) => DataGridRowId;
119
+ selectionMode: DataGridSelectionMode;
120
+ pageSizeOptions: number[];
121
+ loading: boolean;
122
+ emptyMessage: React.ReactNode;
123
+ getRowClassName?: (row: T, index: number) => string;
124
+ onRowClick?: (row: T) => void;
125
+ onRowDoubleClick?: (row: T) => void;
126
+ stickyHeader: boolean;
127
+ }
128
+
129
+ const DataGridContext = createContext<DataGridContextValue<unknown> | null>(null);
130
+
131
+ export function useDataGridContext<T>(): DataGridContextValue<T> {
132
+ const ctx = React.useContext(DataGridContext);
133
+ if (!ctx) {
134
+ throw new Error('useDataGridContext must be used inside <DataGridProvider>');
135
+ }
136
+ return ctx as DataGridContextValue<T>;
137
+ }
138
+
139
+ // =====================================================================
140
+ // Provider
141
+ // =====================================================================
142
+
143
+ export interface DataGridProviderProps<T>
144
+ extends Pick<
145
+ DataGridProps<T>,
146
+ | 'data'
147
+ | 'columns'
148
+ | 'getRowId'
149
+ | 'selectionMode'
150
+ | 'initialSelectedIds'
151
+ | 'onSelectionChange'
152
+ | 'sort'
153
+ | 'onSortChange'
154
+ | 'initialSort'
155
+ | 'filters'
156
+ | 'onFilterChange'
157
+ | 'initialFilters'
158
+ | 'pagination'
159
+ | 'onPaginationChange'
160
+ | 'initialPagination'
161
+ | 'pageSizeOptions'
162
+ | 'loading'
163
+ | 'emptyMessage'
164
+ | 'getRowClassName'
165
+ | 'onRowClick'
166
+ | 'onRowDoubleClick'
167
+ | 'stickyHeader'
168
+ > {
169
+ children: React.ReactNode;
170
+ }
171
+
172
+ const defaultSort: DataGridSortState = { key: null, direction: null };
173
+ const defaultPagination: DataGridPaginationState = { page: 1, pageSize: 25 };
174
+
175
+ export function DataGridProvider<T>(props: DataGridProviderProps<T>) {
176
+ const {
177
+ data,
178
+ columns,
179
+ getRowId,
180
+ selectionMode = 'none',
181
+ initialSelectedIds,
182
+ onSelectionChange,
183
+ sort: controlledSort,
184
+ onSortChange,
185
+ initialSort,
186
+ filters: controlledFilters,
187
+ onFilterChange,
188
+ initialFilters,
189
+ pagination: controlledPagination,
190
+ onPaginationChange,
191
+ initialPagination,
192
+ pageSizeOptions = [10, 25, 50, 100],
193
+ loading = false,
194
+ emptyMessage = 'No data',
195
+ getRowClassName,
196
+ onRowClick,
197
+ onRowDoubleClick,
198
+ stickyHeader = true,
199
+ children,
200
+ } = props;
201
+
202
+ const isControlledSort = controlledSort !== undefined;
203
+ const isControlledFilters = controlledFilters !== undefined;
204
+ const isControlledPagination = controlledPagination !== undefined;
205
+
206
+ const [state, dispatch] = useReducer(reducer<T>, undefined, () => ({
207
+ sort: initialSort ?? defaultSort,
208
+ filters: initialFilters ?? {},
209
+ pagination: initialPagination ?? defaultPagination,
210
+ selected: new Set(initialSelectedIds ?? []),
211
+ }));
212
+
213
+ const effectiveSort = isControlledSort ? controlledSort : state.sort;
214
+ const effectiveFilters = isControlledFilters ? controlledFilters : state.filters;
215
+ const effectivePagination = isControlledPagination
216
+ ? controlledPagination
217
+ : state.pagination;
218
+
219
+ // Sorting
220
+ const sortedData = useMemo(() => {
221
+ if (!effectiveSort.key || !effectiveSort.direction) return data;
222
+ const col = columns.find((c) => c.key === effectiveSort.key);
223
+ if (!col || !col.sortable) return data;
224
+
225
+ const dir = effectiveSort.direction === 'asc' ? 1 : -1;
226
+ return [...data].sort((a, b) => {
227
+ const aVal = (a as Record<string, unknown>)[effectiveSort.key!];
228
+ const bVal = (b as Record<string, unknown>)[effectiveSort.key!];
229
+ if (aVal == null && bVal == null) return 0;
230
+ if (aVal == null) return 1 * dir;
231
+ if (bVal == null) return -1 * dir;
232
+ if (typeof aVal === 'string' && typeof bVal === 'string') {
233
+ return aVal.localeCompare(bVal) * dir;
234
+ }
235
+ if (typeof aVal === 'number' && typeof bVal === 'number') {
236
+ return (aVal - bVal) * dir;
237
+ }
238
+ return String(aVal).localeCompare(String(bVal)) * dir;
239
+ });
240
+ }, [data, effectiveSort, columns]);
241
+
242
+ // Filtering
243
+ const filteredData = useMemo(() => {
244
+ const entries = Object.entries(effectiveFilters);
245
+ if (entries.length === 0) return sortedData;
246
+ return sortedData.filter((row) => {
247
+ for (const [key, value] of entries) {
248
+ if (!value) continue;
249
+ const col = columns.find((c) => c.key === key);
250
+ if (col?.filterFn) {
251
+ if (!col.filterFn(row, value)) return false;
252
+ continue;
253
+ }
254
+ const cellValue = (row as Record<string, unknown>)[key];
255
+ if (cellValue == null) return false;
256
+ if (!String(cellValue).toLowerCase().includes(value.toLowerCase())) return false;
257
+ }
258
+ return true;
259
+ });
260
+ }, [sortedData, effectiveFilters, columns]);
261
+
262
+ // Pagination
263
+ const totalRows = filteredData.length;
264
+ const pageCount = Math.max(1, Math.ceil(totalRows / effectivePagination.pageSize));
265
+ const safePage = Math.min(effectivePagination.page, pageCount);
266
+
267
+ const paginatedData = useMemo(() => {
268
+ const start = (safePage - 1) * effectivePagination.pageSize;
269
+ return filteredData.slice(start, start + effectivePagination.pageSize);
270
+ }, [filteredData, safePage, effectivePagination.pageSize]);
271
+
272
+ // External callbacks via stable refs
273
+ const onSelectionChangeRef = useRef(onSelectionChange);
274
+ const onSortChangeRef = useRef(onSortChange);
275
+ const onFilterChangeRef = useRef(onFilterChange);
276
+ const onPaginationChangeRef = useRef(onPaginationChange);
277
+ onSelectionChangeRef.current = onSelectionChange;
278
+ onSortChangeRef.current = onSortChange;
279
+ onFilterChangeRef.current = onFilterChange;
280
+ onPaginationChangeRef.current = onPaginationChange;
281
+
282
+ useEffect(() => {
283
+ onSelectionChangeRef.current?.([...state.selected]);
284
+ }, [state.selected]);
285
+
286
+ // Actions
287
+ const setSort = useCallback(
288
+ (key: string) => {
289
+ let nextSort: DataGridSortState;
290
+ if (effectiveSort.key === key) {
291
+ const cycle: SortDirection[] = ['asc', 'desc', null];
292
+ const idx = cycle.indexOf(effectiveSort.direction);
293
+ const nextDirection = cycle[(idx + 1) % cycle.length] ?? 'asc';
294
+ nextSort = { key, direction: nextDirection };
295
+ } else {
296
+ nextSort = { key, direction: 'asc' };
297
+ }
298
+ if (isControlledSort) {
299
+ onSortChangeRef.current?.(nextSort);
300
+ } else {
301
+ dispatch({ type: 'set-sort', sort: nextSort });
302
+ }
303
+ },
304
+ [effectiveSort, isControlledSort],
305
+ );
306
+
307
+ const setFilter = useCallback(
308
+ (key: string, value: string) => {
309
+ if (isControlledFilters) {
310
+ const next = { ...effectiveFilters, [key]: value };
311
+ if (value === '') delete next[key];
312
+ onFilterChangeRef.current?.(next);
313
+ } else {
314
+ dispatch({ type: 'set-filter', key, value });
315
+ }
316
+ },
317
+ [isControlledFilters, effectiveFilters],
318
+ );
319
+
320
+ const clearFilters = useCallback(() => {
321
+ if (isControlledFilters) {
322
+ onFilterChangeRef.current?.({});
323
+ } else {
324
+ dispatch({ type: 'clear-filters' });
325
+ }
326
+ }, [isControlledFilters]);
327
+
328
+ const setPage = useCallback(
329
+ (page: number) => {
330
+ const safe = Math.max(1, Math.min(page, pageCount));
331
+ if (isControlledPagination) {
332
+ onPaginationChangeRef.current?.({ ...effectivePagination, page: safe });
333
+ } else {
334
+ dispatch({ type: 'set-page', page: safe });
335
+ }
336
+ },
337
+ [isControlledPagination, effectivePagination, pageCount],
338
+ );
339
+
340
+ const setPageSize = useCallback(
341
+ (pageSize: number) => {
342
+ if (isControlledPagination) {
343
+ onPaginationChangeRef.current?.({ page: 1, pageSize });
344
+ } else {
345
+ dispatch({ type: 'set-page-size', pageSize });
346
+ }
347
+ },
348
+ [isControlledPagination],
349
+ );
350
+
351
+ const select = useCallback(
352
+ (id: DataGridRowId) => dispatch({ type: 'select', id, mode: selectionMode }),
353
+ [selectionMode],
354
+ );
355
+
356
+ const selectMany = useCallback((ids: DataGridRowId[]) => {
357
+ dispatch({ type: 'select-many', ids });
358
+ }, []);
359
+
360
+ const clearSelection = useCallback(() => {
361
+ dispatch({ type: 'clear-selection' });
362
+ }, []);
363
+
364
+ const selectAllVisible = useCallback(() => {
365
+ if (selectionMode !== 'multiple') return;
366
+ const ids = paginatedData.map(getRowId);
367
+ dispatch({ type: 'select-all', ids });
368
+ }, [paginatedData, getRowId, selectionMode]);
369
+
370
+ const isSelected = useCallback(
371
+ (id: DataGridRowId) => state.selected.has(id),
372
+ [state.selected],
373
+ );
374
+
375
+ const value = useMemo<DataGridContextValue<T>>(
376
+ () => ({
377
+ sort: effectiveSort,
378
+ filters: effectiveFilters,
379
+ pagination: { ...effectivePagination, page: safePage },
380
+ selected: state.selected,
381
+ rows: paginatedData,
382
+ totalRows,
383
+ pageCount,
384
+ setSort,
385
+ setFilter,
386
+ clearFilters,
387
+ setPage,
388
+ setPageSize,
389
+ select,
390
+ selectMany,
391
+ clearSelection,
392
+ selectAllVisible,
393
+ isSelected,
394
+ columns,
395
+ getRowId,
396
+ selectionMode,
397
+ pageSizeOptions,
398
+ loading,
399
+ emptyMessage,
400
+ getRowClassName,
401
+ onRowClick,
402
+ onRowDoubleClick,
403
+ stickyHeader,
404
+ }),
405
+ [
406
+ effectiveSort,
407
+ effectiveFilters,
408
+ effectivePagination,
409
+ safePage,
410
+ state.selected,
411
+ paginatedData,
412
+ totalRows,
413
+ pageCount,
414
+ setSort,
415
+ setFilter,
416
+ clearFilters,
417
+ setPage,
418
+ setPageSize,
419
+ select,
420
+ selectMany,
421
+ clearSelection,
422
+ selectAllVisible,
423
+ isSelected,
424
+ columns,
425
+ getRowId,
426
+ selectionMode,
427
+ pageSizeOptions,
428
+ loading,
429
+ emptyMessage,
430
+ getRowClassName,
431
+ onRowClick,
432
+ onRowDoubleClick,
433
+ stickyHeader,
434
+ ],
435
+ );
436
+
437
+ return (
438
+ <DataGridContext.Provider value={value as DataGridContextValue<unknown>}>
439
+ {children}
440
+ </DataGridContext.Provider>
441
+ );
442
+ }
@@ -0,0 +1,4 @@
1
+ 'use client';
2
+
3
+ export { DataGridProvider, useDataGridContext } from './DataGridContext';
4
+ export type { DataGridContextValue, DataGridProviderProps } from './DataGridContext';