@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
|
@@ -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
|
+
}
|