@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.
- package/LICENSE +21 -0
- package/README.md +55 -0
- package/package.json +122 -0
- package/src/DataGrid/README.md +48 -0
- package/src/DataGrid/context/DataGridContext.tsx +442 -0
- package/src/DataGrid/context/index.ts +4 -0
- package/src/DataGrid/index.ts +34 -0
- package/src/DataGrid/lazy.tsx +23 -0
- package/src/DataGrid/parts/DataGrid.tsx +125 -0
- package/src/DataGrid/parts/DataGridCell.tsx +30 -0
- package/src/DataGrid/parts/DataGridHeader.tsx +111 -0
- package/src/DataGrid/parts/DataGridPagination.tsx +105 -0
- package/src/DataGrid/parts/DataGridRow.tsx +84 -0
- package/src/DataGrid/parts/index.ts +7 -0
- package/src/DataGrid/types.ts +95 -0
- package/src/DataTable/README.md +42 -0
- package/src/DataTable/context/DataTableContext.tsx +434 -0
- package/src/DataTable/context/index.ts +4 -0
- package/src/DataTable/index.ts +33 -0
- package/src/DataTable/lazy.tsx +23 -0
- package/src/DataTable/parts/DataTable.tsx +142 -0
- package/src/DataTable/parts/DataTableHeader.tsx +103 -0
- package/src/DataTable/parts/DataTablePagination.tsx +115 -0
- package/src/DataTable/parts/DataTableRow.tsx +96 -0
- package/src/DataTable/parts/index.ts +6 -0
- package/src/DataTable/types.ts +88 -0
- package/src/FileIcon/FileIcon.tsx +87 -0
- package/src/FileIcon/get-file-icon.ts +73 -0
- package/src/FileIcon/icons/icon-data.ts +399 -0
- package/src/FileIcon/index.ts +19 -0
- package/src/FileIcon/loader.ts +29 -0
- package/src/FileIcon/specialFolders.ts +110 -0
- package/src/FileIcon/treeAdapter.tsx +49 -0
- package/src/JsonTree/JsonViewer.tsx +720 -0
- package/src/JsonTree/README.md +142 -0
- package/src/JsonTree/index.tsx +11 -0
- package/src/JsonTree/lazy.tsx +22 -0
- package/src/JsonTree/types.ts +91 -0
- package/src/Kanban/context/KanbanContext.tsx +165 -0
- package/src/Kanban/context/index.ts +4 -0
- package/src/Kanban/index.ts +31 -0
- package/src/Kanban/lazy.tsx +23 -0
- package/src/Kanban/parts/Kanban.tsx +77 -0
- package/src/Kanban/parts/KanbanCard.tsx +76 -0
- package/src/Kanban/parts/KanbanColumn.tsx +79 -0
- package/src/Kanban/parts/KanbanDragOverlay.tsx +46 -0
- package/src/Kanban/parts/index.ts +6 -0
- package/src/Kanban/types.ts +45 -0
- package/src/Listbox/context/ListboxProvider.tsx +616 -0
- package/src/Listbox/context/index.ts +8 -0
- package/src/Listbox/index.ts +28 -0
- package/src/Listbox/lazy.tsx +31 -0
- package/src/Listbox/parts/ListboxGroup.tsx +71 -0
- package/src/Listbox/parts/ListboxItem.tsx +151 -0
- package/src/Listbox/parts/index.ts +12 -0
- package/src/Listbox/types.ts +98 -0
- package/src/Masonry/README.md +79 -0
- package/src/Masonry/context/MasonryProvider.tsx +1205 -0
- package/src/Masonry/context/index.ts +8 -0
- package/src/Masonry/index.ts +18 -0
- package/src/Masonry/lazy.tsx +20 -0
- package/src/Masonry/parts/index.ts +2 -0
- package/src/Masonry/types.ts +44 -0
- package/src/Timeline/context/TimelineProvider.tsx +474 -0
- package/src/Timeline/context/index.ts +16 -0
- package/src/Timeline/index.ts +29 -0
- package/src/Timeline/lazy.tsx +27 -0
- package/src/Timeline/parts/index.ts +2 -0
- package/src/Timeline/types.ts +25 -0
- package/src/Tree/FinderTree.tsx +42 -0
- package/src/Tree/README.md +542 -0
- package/src/Tree/TreeDndProvider.tsx +137 -0
- package/src/Tree/TreeRoot.tsx +290 -0
- package/src/Tree/components/TreeChevron.tsx +48 -0
- package/src/Tree/components/TreeContent.tsx +70 -0
- package/src/Tree/components/TreeDropIndicator.tsx +65 -0
- package/src/Tree/components/TreeEmpty.tsx +22 -0
- package/src/Tree/components/TreeEmptyArea.tsx +160 -0
- package/src/Tree/components/TreeError.tsx +24 -0
- package/src/Tree/components/TreeIcon.tsx +39 -0
- package/src/Tree/components/TreeIndentGuides.tsx +33 -0
- package/src/Tree/components/TreeLabel.tsx +39 -0
- package/src/Tree/components/TreeRenameInput.tsx +114 -0
- package/src/Tree/components/TreeRow.tsx +300 -0
- package/src/Tree/components/TreeSearchInput.tsx +50 -0
- package/src/Tree/components/TreeSkeleton.tsx +22 -0
- package/src/Tree/components/index.ts +28 -0
- package/src/Tree/context/TreeContext.tsx +409 -0
- package/src/Tree/context/TreeContextValue.ts +139 -0
- package/src/Tree/context/async-children/collect-ids.ts +27 -0
- package/src/Tree/context/async-children/index.ts +8 -0
- package/src/Tree/context/async-children/use-async-children.ts +157 -0
- package/src/Tree/context/clipboard/index.ts +4 -0
- package/src/Tree/context/clipboard/use-clipboard.ts +115 -0
- package/src/Tree/context/dnd/index.ts +8 -0
- package/src/Tree/context/dnd/use-dnd.ts +194 -0
- package/src/Tree/context/expansion/index.ts +4 -0
- package/src/Tree/context/expansion/use-expansion.ts +55 -0
- package/src/Tree/context/hooks.ts +177 -0
- package/src/Tree/context/index.ts +16 -0
- package/src/Tree/context/menu/builtin-actions.ts +358 -0
- package/src/Tree/context/menu/index.ts +11 -0
- package/src/Tree/context/menu/render.tsx +104 -0
- package/src/Tree/context/menu/use-resolved-menu.ts +141 -0
- package/src/Tree/context/persist/index.ts +4 -0
- package/src/Tree/context/persist/use-persist-sync.ts +74 -0
- package/src/Tree/context/rename/index.ts +4 -0
- package/src/Tree/context/rename/use-rename.ts +113 -0
- package/src/Tree/context/selection/index.ts +4 -0
- package/src/Tree/context/selection/use-selection.ts +146 -0
- package/src/Tree/context/state/index.ts +6 -0
- package/src/Tree/context/state/initial.ts +41 -0
- package/src/Tree/context/state/reducer.ts +76 -0
- package/src/Tree/context/state/types.ts +46 -0
- package/src/Tree/data/appearance.ts +237 -0
- package/src/Tree/data/childCache.ts +43 -0
- package/src/Tree/data/clipboard.ts +33 -0
- package/src/Tree/data/createDemoTree.ts +42 -0
- package/src/Tree/data/dnd.ts +123 -0
- package/src/Tree/data/finderShortcuts.ts +67 -0
- package/src/Tree/data/flatten.ts +66 -0
- package/src/Tree/data/index.ts +45 -0
- package/src/Tree/data/persist.ts +62 -0
- package/src/Tree/data/renameUtils.ts +51 -0
- package/src/Tree/data/selection.ts +161 -0
- package/src/Tree/hooks/finder-hotkeys/build-ctx.ts +48 -0
- package/src/Tree/hooks/finder-hotkeys/index.ts +8 -0
- package/src/Tree/hooks/finder-hotkeys/use-tree-finder-hotkeys.ts +166 -0
- package/src/Tree/hooks/index.ts +25 -0
- package/src/Tree/hooks/keyboard/activation.ts +27 -0
- package/src/Tree/hooks/keyboard/arrow-nav.ts +26 -0
- package/src/Tree/hooks/keyboard/expand-collapse.ts +54 -0
- package/src/Tree/hooks/keyboard/index.ts +10 -0
- package/src/Tree/hooks/keyboard/types.ts +39 -0
- package/src/Tree/hooks/keyboard/use-tree-keyboard.ts +196 -0
- package/src/Tree/hooks/type-ahead/index.ts +5 -0
- package/src/Tree/hooks/type-ahead/match-prefix.ts +42 -0
- package/src/Tree/hooks/type-ahead/use-tree-type-ahead.ts +89 -0
- package/src/Tree/index.tsx +135 -0
- package/src/Tree/lazy.tsx +14 -0
- package/src/Tree/types/activation.ts +30 -0
- package/src/Tree/types/adapter.ts +70 -0
- package/src/Tree/types/index.ts +27 -0
- package/src/Tree/types/labels.ts +97 -0
- package/src/Tree/types/loader.ts +9 -0
- package/src/Tree/types/node.ts +38 -0
- package/src/Tree/types/root-props.ts +158 -0
- package/src/Tree/types/selection.ts +3 -0
- package/src/Tree/types/slots.ts +64 -0
- package/src/index.ts +21 -0
- package/src/stories/_fixtures/categories.ts +51 -0
- package/src/stories/_fixtures/finder-store.ts +196 -0
- package/src/stories/_fixtures/fs.ts +83 -0
- package/src/stories/_fixtures/index.ts +5 -0
- package/src/stories/_fixtures/org.ts +46 -0
- package/src/stories/_fixtures/status.ts +21 -0
- 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
|
+
}
|