@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
@@ -0,0 +1,137 @@
1
+ 'use client';
2
+
3
+ import {
4
+ DndContext,
5
+ KeyboardSensor,
6
+ PointerSensor,
7
+ useSensor,
8
+ useSensors,
9
+ type DragEndEvent,
10
+ type DragMoveEvent,
11
+ type DragStartEvent,
12
+ } from '@dnd-kit/core';
13
+ import { useCallback, useRef } from 'react';
14
+
15
+ import { useTreeContext } from './context/TreeContext';
16
+ import { resolveDropZone, TREE_ROOT_DROP_ID } from './data/dnd';
17
+ import type { TreeItemId } from './types';
18
+
19
+ interface TreeDndProviderProps {
20
+ children: React.ReactNode;
21
+ }
22
+
23
+ /**
24
+ * Wrap Tree's body in a `<DndContext>` when DnD is enabled.
25
+ *
26
+ * One central drag handler decides the drop zone (`before` / `inside` /
27
+ * `after`) from the cursor position vs. the hovered row's bounding box.
28
+ * Rows themselves only register `useDraggable` + `useDroppable` — no
29
+ * per-row `onPointerMove`, so dragging across 1000 rows stays cheap.
30
+ *
31
+ * Sensors: pointer (with a small activation distance so a plain click
32
+ * doesn't initiate a drag) + keyboard (drag with Space/Enter, arrows,
33
+ * Space drops — built-in accessibility).
34
+ */
35
+ export function TreeDndProvider({ children }: TreeDndProviderProps) {
36
+ const ctx = useTreeContext();
37
+ const { dnd } = ctx;
38
+
39
+ const sensors = useSensors(
40
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
41
+ useSensor(KeyboardSensor),
42
+ );
43
+
44
+ // Latest cursor position — @dnd-kit gives us the active rectangle but
45
+ // not the raw pointer Y. We grab it from pointermove ourselves and
46
+ // use it inside `onDragMove` to compute the drop zone.
47
+ const cursorYRef = useRef(0);
48
+
49
+ const handleDragStart = useCallback(
50
+ (e: DragStartEvent) => {
51
+ dnd.beginDrag(e.active.id as TreeItemId);
52
+ },
53
+ [dnd],
54
+ );
55
+
56
+ const handleDragMove = useCallback(
57
+ (e: DragMoveEvent) => {
58
+ const overId = e.over?.id;
59
+ // Root drop target — TreeEmptyArea registers under this id.
60
+ if (overId === TREE_ROOT_DROP_ID) {
61
+ const current = dnd.dropTarget;
62
+ if (current?.id !== null || current?.position !== 'inside') {
63
+ dnd.setDropTarget({ id: null, position: 'inside' });
64
+ }
65
+ return;
66
+ }
67
+ if (typeof overId !== 'string') {
68
+ if (dnd.dropTarget !== null) dnd.setDropTarget(null);
69
+ return;
70
+ }
71
+
72
+ // The dnd-kit `over.rect` is the droppable's bounding box. Combined
73
+ // with the latest cursor Y we recover the same before/inside/after
74
+ // split Finder uses.
75
+ const rect = e.over?.rect;
76
+ if (!rect) return;
77
+
78
+ // `data-folder` on the dom row tells us if `inside` is allowed.
79
+ const el = document.querySelector<HTMLElement>(
80
+ `[data-tree-row][data-id="${CSS.escape(overId)}"]`,
81
+ );
82
+ const isFolder = el?.dataset.folder === 'true';
83
+
84
+ const position = resolveDropZone({
85
+ pointerY: cursorYRef.current,
86
+ rowRect: { top: rect.top, bottom: rect.top + rect.height, height: rect.height },
87
+ isFolder,
88
+ });
89
+
90
+ const current = dnd.dropTarget;
91
+ if (current?.id !== overId || current.position !== position) {
92
+ dnd.setDropTarget({ id: overId, position });
93
+ }
94
+ },
95
+ [dnd],
96
+ );
97
+
98
+ const handleDragEnd = useCallback(
99
+ async (_e: DragEndEvent) => {
100
+ await dnd.commitDrop();
101
+ },
102
+ [dnd],
103
+ );
104
+
105
+ const handleDragCancel = useCallback(() => {
106
+ dnd.cancelDrag();
107
+ }, [dnd]);
108
+
109
+ // Track the latest cursor Y position globally while a drag is in flight.
110
+ // Used by `handleDragMove` to figure out which third of the row we're
111
+ // over. Cheap — one passive listener, no re-renders.
112
+ const handlePointerMove = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
113
+ cursorYRef.current = e.clientY;
114
+ }, []);
115
+
116
+ if (!dnd.active) {
117
+ return <>{children}</>;
118
+ }
119
+
120
+ return (
121
+ <DndContext
122
+ sensors={sensors}
123
+ onDragStart={handleDragStart}
124
+ onDragMove={handleDragMove}
125
+ onDragEnd={handleDragEnd}
126
+ onDragCancel={handleDragCancel}
127
+ >
128
+ <div
129
+ onPointerMove={handlePointerMove}
130
+ className="contents"
131
+ data-tree-dnd-surface=""
132
+ >
133
+ {children}
134
+ </div>
135
+ </DndContext>
136
+ );
137
+ }
@@ -0,0 +1,290 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useRef } from 'react';
4
+ import { cn } from '@djangocfg/ui-core/lib';
5
+
6
+ import { TreeProvider, useTreeContext } from './context/TreeContext';
7
+ import { TreeDndProvider } from './TreeDndProvider';
8
+ import { TreeContent, treeRowDomId } from './components/TreeContent';
9
+ import { TreeEmptyArea } from './components/TreeEmptyArea';
10
+ import { TreeSearchInput } from './components/TreeSearchInput';
11
+ import { appearanceToStyle } from './data/appearance';
12
+ import {
13
+ useTreeKeyboard,
14
+ useTreeTypeAhead,
15
+ useTreeFinderHotkeys,
16
+ } from './hooks';
17
+ import type { TreeActionsHandle, TreeRootProps } from './types';
18
+
19
+ /**
20
+ * High-level entry point. Wraps Provider + (optional) search bar + content.
21
+ *
22
+ * For full control, compose with <TreeProvider>, <TreeContent>,
23
+ * <TreeSearchInput>, <TreeRow>, etc. directly from `@djangocfg/widget-data/tree`.
24
+ */
25
+ function TreeRoot<T>(props: TreeRootProps<T>) {
26
+ const {
27
+ data,
28
+ getItemName,
29
+ loadChildren,
30
+ selectionMode,
31
+ activationMode,
32
+ initialExpandedIds,
33
+ initialSelectedIds,
34
+ indent,
35
+ appearance,
36
+ onSelectionChange,
37
+ onExpansionChange,
38
+ onActivate,
39
+ filterNode,
40
+ enableSearch = false,
41
+ enableTypeAhead = true,
42
+ showIndentGuides = false,
43
+ enableInlineRename = false,
44
+ enableFinderHotkeys = false,
45
+ enableDnD = false,
46
+ canDrop,
47
+ renderRow,
48
+ renderIcon,
49
+ renderLabel,
50
+ renderActions,
51
+ renderContextMenu,
52
+ contextMenuActions,
53
+ labels,
54
+ persistKey,
55
+ persistSelection = false,
56
+ adapter,
57
+ defaultMenuItems,
58
+ actionsRef,
59
+ className,
60
+ style,
61
+ } = props;
62
+
63
+ return (
64
+ <TreeProvider<T>
65
+ data={data}
66
+ getItemName={getItemName}
67
+ loadChildren={loadChildren}
68
+ selectionMode={selectionMode}
69
+ activationMode={activationMode}
70
+ initialExpandedIds={initialExpandedIds}
71
+ initialSelectedIds={initialSelectedIds}
72
+ indent={indent}
73
+ appearance={appearance}
74
+ onSelectionChange={onSelectionChange}
75
+ onExpansionChange={onExpansionChange}
76
+ onActivate={onActivate}
77
+ filterNode={filterNode}
78
+ enableSearch={enableSearch}
79
+ showIndentGuides={showIndentGuides}
80
+ renderIcon={renderIcon}
81
+ renderLabel={renderLabel}
82
+ renderActions={renderActions}
83
+ // The provider builds the *declarative* merged resolver. Slot
84
+ // conversion happens inside <TreeRootShell /> via the inner ctx,
85
+ // so built-in actions can read live selection state.
86
+ renderContextMenu={renderContextMenu}
87
+ contextMenuActions={contextMenuActions}
88
+ adapter={adapter}
89
+ defaultMenuItems={defaultMenuItems}
90
+ enableInlineRename={enableInlineRename}
91
+ enableDnD={enableDnD}
92
+ canDrop={canDrop}
93
+ labels={labels}
94
+ persistKey={persistKey}
95
+ persistSelection={persistSelection}
96
+ >
97
+ <TreeRootShell<T>
98
+ className={className}
99
+ style={style}
100
+ enableSearch={enableSearch}
101
+ enableTypeAhead={enableTypeAhead}
102
+ enableFinderHotkeys={enableFinderHotkeys}
103
+ renderRow={renderRow}
104
+ actionsRef={actionsRef}
105
+ />
106
+ </TreeProvider>
107
+ );
108
+ }
109
+
110
+ interface TreeRootShellProps<T> {
111
+ className?: string;
112
+ style?: React.CSSProperties;
113
+ enableSearch: boolean;
114
+ enableTypeAhead: boolean;
115
+ enableFinderHotkeys: boolean;
116
+ renderRow?: TreeRootProps<T>['renderRow'];
117
+ actionsRef?: React.MutableRefObject<TreeActionsHandle | null>;
118
+ }
119
+
120
+ function TreeRootShell<T>({
121
+ className,
122
+ style,
123
+ enableSearch,
124
+ enableTypeAhead,
125
+ enableFinderHotkeys,
126
+ renderRow,
127
+ actionsRef,
128
+ }: TreeRootShellProps<T>) {
129
+ const containerRef = useRef<HTMLDivElement>(null);
130
+ const ctx = useTreeContext<T>();
131
+
132
+ // Publish the action handle to the outer ref so host code can call
133
+ // refresh / refreshAll after a mutation that originated outside Tree.
134
+ // Effect runs after mount; the ref stays populated until unmount.
135
+ useEffect(() => {
136
+ if (!actionsRef) return;
137
+ actionsRef.current = {
138
+ refresh: ctx.refresh,
139
+ refreshAll: ctx.refreshAll,
140
+ expandAll: ctx.expandAll,
141
+ collapseAll: ctx.collapseAll,
142
+ };
143
+ return () => {
144
+ if (actionsRef.current) actionsRef.current = null;
145
+ };
146
+ }, [
147
+ actionsRef,
148
+ ctx.refresh,
149
+ ctx.refreshAll,
150
+ ctx.expandAll,
151
+ ctx.collapseAll,
152
+ ]);
153
+
154
+ // Keyboard navigation (↑↓ ←→ Home/End Enter Esc Cmd+A, Shift-extend) —
155
+ // scoped via callback ref.
156
+ const isMulti = ctx.selectionMode === 'multiple';
157
+ const { ref: keyboardRef } = useTreeKeyboard<T>({
158
+ rows: ctx.flatRows,
159
+ focusedId: ctx.focused,
160
+ multiSelect: isMulti,
161
+ // Pause container hotkeys while inline rename is active so the
162
+ // user can type freely (TreeRenameInput stops bubbling already, but
163
+ // gating here is the cleaner second line of defence).
164
+ enabled: ctx.renamingId === null,
165
+ onFocus: (id, { extend }) => {
166
+ if (extend && isMulti) {
167
+ ctx.moveSelect(id, { extend: true });
168
+ } else {
169
+ ctx.setFocus(id);
170
+ }
171
+ },
172
+ onSelect: ctx.select,
173
+ onActivate: (id) => {
174
+ // Keyboard Enter / Space is always an explicit action — pin (no preview).
175
+ const row = ctx.flatRows.find((r) => r.node.id === id);
176
+ if (row) ctx.activate(row.node, { preview: false });
177
+ },
178
+ onExpand: ctx.expand,
179
+ onCollapse: ctx.collapse,
180
+ onClearSelection: ctx.clearSelection,
181
+ onSelectAll: ctx.selectAll,
182
+ });
183
+
184
+ // Finder hotkeys (P4) — ⌘⌫, F2, ⌘D, ⌘N, ⌘⇧N, ⌘C/X/V. Bound only when
185
+ // `enableFinderHotkeys` is true; individual shortcuts further gated
186
+ // by adapter method availability inside the hook.
187
+ const { ref: finderHotkeysRef } = useTreeFinderHotkeys<T>({
188
+ enabled: enableFinderHotkeys,
189
+ paused: ctx.renamingId !== null,
190
+ adapter: ctx.adapter,
191
+ labels: ctx.labels,
192
+ selected: ctx.selected,
193
+ focused: ctx.focused,
194
+ getNodeById: ctx.getNodeById,
195
+ getItemName: ctx.getItemName,
196
+ startInlineRename: ctx.inlineRenameEnabled ? ctx.startRename : undefined,
197
+ clipboard: {
198
+ hasItems: !!ctx.clipboard && ctx.clipboard.ids.length > 0,
199
+ cut: ctx.cutToClipboard,
200
+ copy: ctx.copyToClipboard,
201
+ // Hotkey paste targets the currently focused row (or null = root).
202
+ paste: () => {
203
+ const target = ctx.focused ? ctx.getNodeById(ctx.focused) ?? null : null;
204
+ return ctx.pasteFromClipboard(target, 'inside');
205
+ },
206
+ },
207
+ });
208
+
209
+ const setContainerRef = useCallback(
210
+ (instance: HTMLDivElement | null) => {
211
+ containerRef.current = instance;
212
+ keyboardRef(instance);
213
+ finderHotkeysRef(instance);
214
+ },
215
+ [keyboardRef, finderHotkeysRef],
216
+ );
217
+
218
+ // Keep the focused row scrolled into view whenever focus moves (keyboard
219
+ // nav, type-ahead, programmatic). Centralised so every focus source gets
220
+ // consistent scrolling — previously only type-ahead scrolled.
221
+ const focusedId = ctx.focused;
222
+ const isEmpty = ctx.flatRows.length === 0;
223
+ useEffect(() => {
224
+ if (!focusedId) return;
225
+ const el = containerRef.current?.querySelector<HTMLElement>(
226
+ `[data-tree-row][data-id="${CSS.escape(focusedId)}"]`,
227
+ );
228
+ el?.scrollIntoView({ block: 'nearest' });
229
+ }, [focusedId]);
230
+
231
+ // Type-ahead jump — focus update; scrolling handled by the effect above.
232
+ const onTypeAheadMatch = useCallback(
233
+ (id: string) => {
234
+ ctx.setFocus(id);
235
+ },
236
+ [ctx],
237
+ );
238
+
239
+ useTreeTypeAhead<T>({
240
+ rows: ctx.flatRows,
241
+ getItemName: ctx.getItemName,
242
+ containerRef,
243
+ onMatch: onTypeAheadMatch,
244
+ enabled: enableTypeAhead,
245
+ });
246
+
247
+ // Tree body. `ctx.renderContextMenu` is already the final slot —
248
+ // TreeProvider does the declarative→slot merge centrally, so Shell
249
+ // doesn't re-derive it (and no nested provider override is needed).
250
+ const treeBody = (
251
+ <div
252
+ ref={setContainerRef}
253
+ tabIndex={isEmpty ? undefined : 0}
254
+ role={isEmpty ? undefined : 'tree'}
255
+ aria-label={isEmpty ? undefined : ctx.labels.ariaLabel}
256
+ aria-multiselectable={
257
+ !isEmpty && ctx.selectionMode === 'multiple' ? true : undefined
258
+ }
259
+ aria-activedescendant={
260
+ !isEmpty && focusedId ? treeRowDomId(focusedId) : undefined
261
+ }
262
+ className={cn(
263
+ 'group/tree flex h-full w-full flex-col gap-2 rounded-sm outline-none',
264
+ 'focus-visible:ring-1 focus-visible:ring-ring/50',
265
+ className,
266
+ )}
267
+ style={{ ...appearanceToStyle(ctx.appearance), ...style }}
268
+ data-tree-root=""
269
+ >
270
+ {enableSearch ? <TreeSearchInput className="mx-2 mt-2" /> : null}
271
+ <div className="flex min-h-0 flex-1 flex-col overflow-auto px-1">
272
+ <TreeContent<T> role={isEmpty ? "presentation" : "group"}>
273
+ {renderRow}
274
+ </TreeContent>
275
+ {/* Empty-area: catches right-clicks on whitespace below the
276
+ last row + acts as the root drop target for DnD. Always
277
+ rendered — it self-disables when there's nothing to do. */}
278
+ <TreeEmptyArea />
279
+ </div>
280
+ </div>
281
+ );
282
+
283
+ // Wrap in @dnd-kit context only when DnD is active — `TreeDndProvider`
284
+ // short-circuits to a fragment otherwise, so we don't pay the
285
+ // sensor-registration cost.
286
+ return <TreeDndProvider>{treeBody}</TreeDndProvider>;
287
+ }
288
+
289
+ export default TreeRoot;
290
+ export { TreeRoot };
@@ -0,0 +1,48 @@
1
+ 'use client';
2
+
3
+ import { ChevronDown, ChevronRight } from 'lucide-react';
4
+ import { memo } from 'react';
5
+ import { cn } from '@djangocfg/ui-core/lib';
6
+
7
+ import { useTreeContext } from '../context/TreeContext';
8
+
9
+ export interface TreeChevronProps {
10
+ isExpanded: boolean;
11
+ isFolder: boolean;
12
+ className?: string;
13
+ }
14
+
15
+ /**
16
+ * TreeChevron — expand/collapse chevron for a tree row.
17
+ *
18
+ * Memoised: re-renders only when `isExpanded`, `isFolder` or `className`
19
+ * change. Reads appearance from context.
20
+ */
21
+ function TreeChevronRaw({ isExpanded, isFolder, className }: TreeChevronProps) {
22
+ const { appearance } = useTreeContext();
23
+ const size = { width: 'var(--tree-icon-size)', height: 'var(--tree-icon-size)' };
24
+
25
+ if (!isFolder) {
26
+ return (
27
+ <span
28
+ aria-hidden
29
+ style={size}
30
+ className={cn('inline-block shrink-0', className)}
31
+ />
32
+ );
33
+ }
34
+ const Icon = isExpanded ? ChevronDown : ChevronRight;
35
+ return (
36
+ <Icon
37
+ aria-hidden
38
+ strokeWidth={appearance.iconStrokeWidth}
39
+ style={size}
40
+ className={cn(
41
+ 'shrink-0 text-muted-foreground/70 transition-transform',
42
+ className,
43
+ )}
44
+ />
45
+ );
46
+ }
47
+
48
+ export const TreeChevron = memo(TreeChevronRaw);
@@ -0,0 +1,70 @@
1
+ 'use client';
2
+
3
+ import { Fragment, type ReactNode } from 'react';
4
+ import { cn } from '@djangocfg/ui-core/lib';
5
+
6
+ import { useTreeContext } from '../context/TreeContext';
7
+ import { appearanceToStyle } from '../data/appearance';
8
+ import type { FlatRow, TreeRowRenderProps, TreeRowSlot } from '../types';
9
+ import { TreeRow } from './TreeRow';
10
+ import { TreeEmpty } from './TreeEmpty';
11
+
12
+ export interface TreeContentProps<T> {
13
+ /** Custom row renderer; falls back to <TreeRow />. */
14
+ children?: TreeRowSlot<T>;
15
+ className?: string;
16
+ /** Override aria-label for the container. */
17
+ ariaLabel?: string;
18
+ /**
19
+ * Container ARIA role. Defaults to `'tree'` for standalone composition
20
+ * use. `<TreeRoot>` passes `'group'` because its own outer element
21
+ * already carries `role="tree"` + `aria-activedescendant`.
22
+ */
23
+ role?: 'tree' | 'group' | 'presentation';
24
+ }
25
+
26
+ /** DOM id for a tree row — referenced by `aria-activedescendant`. */
27
+ export const treeRowDomId = (id: string) => `tree-row-${id}`;
28
+
29
+ export function TreeContent<T>({
30
+ children,
31
+ className,
32
+ ariaLabel,
33
+ role = 'tree',
34
+ }: TreeContentProps<T>) {
35
+ const { flatRows, labels, selected, focused, matchingIds, appearance, selectionMode } =
36
+ useTreeContext<T>();
37
+
38
+ if (flatRows.length === 0) {
39
+ return <TreeEmpty>{labels.empty}</TreeEmpty>;
40
+ }
41
+
42
+ const isTree = role === 'tree';
43
+
44
+ return (
45
+ <div
46
+ role={role}
47
+ aria-label={isTree ? (ariaLabel ?? labels.ariaLabel) : undefined}
48
+ aria-multiselectable={
49
+ isTree && selectionMode === 'multiple' ? true : undefined
50
+ }
51
+ className={cn('relative flex flex-col py-1', className)}
52
+ style={appearanceToStyle(appearance)}
53
+ >
54
+ {flatRows.map((row: FlatRow<T>) => {
55
+ const slot: TreeRowRenderProps<T> = {
56
+ node: row.node,
57
+ level: row.level,
58
+ isSelected: selected.has(row.node.id),
59
+ isExpanded: row.isExpanded,
60
+ isFocused: focused === row.node.id,
61
+ isFolder: row.isFolder,
62
+ isLoading: row.isLoading,
63
+ isMatchingSearch: matchingIds.has(row.node.id),
64
+ };
65
+ const node: ReactNode = children ? children(slot) : <TreeRow row={row} />;
66
+ return <Fragment key={row.node.id}>{node}</Fragment>;
67
+ })}
68
+ </div>
69
+ );
70
+ }
@@ -0,0 +1,65 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ import type { TreeMovePosition } from '../types';
6
+
7
+ export interface TreeDropIndicatorProps {
8
+ position: TreeMovePosition;
9
+ /** Indent in pixels — keeps the line aligned with the row's text. */
10
+ indent: number;
11
+ /** Render a "rejected" style (red wash) when the drop is forbidden. */
12
+ invalid?: boolean;
13
+ }
14
+
15
+ /**
16
+ * Visual hint for an in-progress drag-and-drop. Three modes:
17
+ *
18
+ * - `before` / `after` → a 2px horizontal line above / below the row
19
+ * - `inside` → a translucent primary wash filling the row
20
+ *
21
+ * Indents the line by the row's depth so it visually aligns with the
22
+ * target's content (rather than running edge-to-edge).
23
+ *
24
+ * Positioned absolutely — the parent `TreeRow` provides `position: relative`.
25
+ */
26
+ export function TreeDropIndicator({
27
+ position,
28
+ indent,
29
+ invalid = false,
30
+ }: TreeDropIndicatorProps) {
31
+ if (position === 'inside') {
32
+ return (
33
+ <span
34
+ aria-hidden
35
+ data-tree-drop="inside"
36
+ className={cn(
37
+ 'pointer-events-none absolute inset-0 rounded-sm ring-1',
38
+ invalid
39
+ ? 'bg-destructive/10 ring-destructive/40'
40
+ : 'bg-primary/10 ring-primary/40',
41
+ )}
42
+ />
43
+ );
44
+ }
45
+
46
+ const isBefore = position === 'before';
47
+ return (
48
+ <span
49
+ aria-hidden
50
+ data-tree-drop={position}
51
+ style={{ paddingLeft: indent }}
52
+ className={cn(
53
+ 'pointer-events-none absolute right-0 left-0 h-px',
54
+ isBefore ? 'top-0' : 'bottom-0',
55
+ )}
56
+ >
57
+ <span
58
+ className={cn(
59
+ 'block h-0.5 rounded-full',
60
+ invalid ? 'bg-destructive/70' : 'bg-primary',
61
+ )}
62
+ />
63
+ </span>
64
+ );
65
+ }
@@ -0,0 +1,22 @@
1
+ 'use client';
2
+
3
+ import { cn } from '@djangocfg/ui-core/lib';
4
+
5
+ export interface TreeEmptyProps {
6
+ children: React.ReactNode;
7
+ className?: string;
8
+ }
9
+
10
+ export function TreeEmpty({ children, className }: TreeEmptyProps) {
11
+ return (
12
+ <div
13
+ role="status"
14
+ className={cn(
15
+ 'flex h-full min-h-32 items-center justify-center px-4 py-6 text-sm text-muted-foreground',
16
+ className,
17
+ )}
18
+ >
19
+ {children}
20
+ </div>
21
+ );
22
+ }