@headless-tree/core 0.0.15 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/lib/cjs/features/drag-and-drop/feature.js +3 -3
- package/lib/cjs/features/drag-and-drop/utils.d.ts +7 -0
- package/lib/cjs/features/drag-and-drop/utils.js +3 -1
- package/lib/cjs/features/hotkeys-core/feature.js +5 -5
- package/lib/cjs/features/keyboard-drag-and-drop/feature.js +3 -4
- package/lib/cjs/features/prop-memoization/feature.js +32 -10
- package/lib/cjs/features/prop-memoization/types.d.ts +6 -1
- package/lib/cjs/features/selection/feature.js +2 -2
- package/lib/cjs/features/tree/feature.js +17 -10
- package/lib/cjs/utilities/errors.d.ts +1 -0
- package/lib/cjs/utilities/errors.js +6 -2
- package/lib/esm/features/drag-and-drop/feature.js +4 -4
- package/lib/esm/features/drag-and-drop/utils.d.ts +7 -0
- package/lib/esm/features/drag-and-drop/utils.js +1 -0
- package/lib/esm/features/hotkeys-core/feature.js +5 -5
- package/lib/esm/features/keyboard-drag-and-drop/feature.js +4 -5
- package/lib/esm/features/prop-memoization/feature.js +32 -10
- package/lib/esm/features/prop-memoization/types.d.ts +6 -1
- package/lib/esm/features/selection/feature.js +2 -2
- package/lib/esm/features/tree/feature.js +17 -10
- package/lib/esm/utilities/errors.d.ts +1 -0
- package/lib/esm/utilities/errors.js +4 -1
- package/package.json +1 -1
- package/readme.md +157 -0
- package/src/features/drag-and-drop/feature.ts +9 -4
- package/src/features/drag-and-drop/utils.ts +3 -0
- package/src/features/hotkeys-core/feature.ts +5 -5
- package/src/features/keyboard-drag-and-drop/feature.ts +4 -4
- package/src/features/keyboard-drag-and-drop/keyboard-drag-and-drop.spec.ts +2 -1
- package/src/features/prop-memoization/feature.ts +26 -7
- package/src/features/prop-memoization/types.ts +6 -1
- package/src/features/search/types.ts +1 -1
- package/src/features/selection/feature.ts +2 -2
- package/src/features/tree/feature.ts +22 -10
- package/src/utilities/errors.ts +6 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# @headless-tree/core
|
|
2
2
|
|
|
3
|
+
## 1.0.1
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- c9f9932: fixed tree.focusNextItem() and tree.focusPreviousItem() throwing if no item is currently focused
|
|
8
|
+
- 6ed84b4: recursive item references are filtered out when rendering (#89)
|
|
9
|
+
- 4bef2f2: fixed a bug where hotkeys involving shift may not work properly depending on the order of shift and other key inputs (#98)
|
|
10
|
+
|
|
11
|
+
## 1.0.0
|
|
12
|
+
|
|
13
|
+
### Minor Changes
|
|
14
|
+
|
|
15
|
+
- 9e5027b: The propMemoization feature now memoizes all prop-generation related functions, including searchinput and renameinput related props
|
|
16
|
+
|
|
3
17
|
## 0.0.15
|
|
4
18
|
|
|
5
19
|
### Patch Changes
|
|
@@ -29,7 +29,7 @@ exports.dragAndDropFeature = {
|
|
|
29
29
|
const target = tree.getDragTarget();
|
|
30
30
|
const indent = ((_a = target === null || target === void 0 ? void 0 : target.item.getItemMeta().level) !== null && _a !== void 0 ? _a : 0) + 1;
|
|
31
31
|
const treeBb = (_b = tree.getElement()) === null || _b === void 0 ? void 0 : _b.getBoundingClientRect();
|
|
32
|
-
if (!target || !treeBb || !(
|
|
32
|
+
if (!target || !treeBb || !(0, utils_1.isOrderedDragTarget)(target))
|
|
33
33
|
return null;
|
|
34
34
|
const leftOffset = target.dragLineLevel * ((_c = tree.getConfig().indent) !== null && _c !== void 0 ? _c : 1);
|
|
35
35
|
const targetItem = tree.getItems()[target.dragLineIndex];
|
|
@@ -157,7 +157,7 @@ exports.dragAndDropFeature = {
|
|
|
157
157
|
isDragTargetAbove: ({ tree, item }) => {
|
|
158
158
|
const target = tree.getDragTarget();
|
|
159
159
|
if (!target ||
|
|
160
|
-
!(
|
|
160
|
+
!(0, utils_1.isOrderedDragTarget)(target) ||
|
|
161
161
|
target.item !== item.getParent())
|
|
162
162
|
return false;
|
|
163
163
|
return target.childIndex === item.getItemMeta().posInSet;
|
|
@@ -165,7 +165,7 @@ exports.dragAndDropFeature = {
|
|
|
165
165
|
isDragTargetBelow: ({ tree, item }) => {
|
|
166
166
|
const target = tree.getDragTarget();
|
|
167
167
|
if (!target ||
|
|
168
|
-
!(
|
|
168
|
+
!(0, utils_1.isOrderedDragTarget)(target) ||
|
|
169
169
|
target.item !== item.getParent())
|
|
170
170
|
return false;
|
|
171
171
|
return target.childIndex - 1 === item.getItemMeta().posInSet;
|
|
@@ -5,6 +5,13 @@ export declare enum ItemDropCategory {
|
|
|
5
5
|
ExpandedFolder = 1,
|
|
6
6
|
LastInGroup = 2
|
|
7
7
|
}
|
|
8
|
+
export declare const isOrderedDragTarget: <T>(dragTarget: DragTarget<T>) => dragTarget is {
|
|
9
|
+
item: ItemInstance<T>;
|
|
10
|
+
childIndex: number;
|
|
11
|
+
insertionIndex: number;
|
|
12
|
+
dragLineIndex: number;
|
|
13
|
+
dragLineLevel: number;
|
|
14
|
+
};
|
|
8
15
|
export declare const canDrop: (dataTransfer: DataTransfer | null, target: DragTarget<any>, tree: TreeInstance<any>) => boolean;
|
|
9
16
|
export declare const getItemDropCategory: (item: ItemInstance<any>) => ItemDropCategory;
|
|
10
17
|
export declare const getInsertionIndex: <T>(children: ItemInstance<T>[], childIndex: number, draggedItems: ItemInstance<T>[] | undefined) => number;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.getDragTarget = exports.getReparentTarget = exports.getDragCode = exports.getInsertionIndex = exports.getItemDropCategory = exports.canDrop = exports.ItemDropCategory = void 0;
|
|
3
|
+
exports.getDragTarget = exports.getReparentTarget = exports.getDragCode = exports.getInsertionIndex = exports.getItemDropCategory = exports.canDrop = exports.isOrderedDragTarget = exports.ItemDropCategory = void 0;
|
|
4
4
|
var ItemDropCategory;
|
|
5
5
|
(function (ItemDropCategory) {
|
|
6
6
|
ItemDropCategory[ItemDropCategory["Item"] = 0] = "Item";
|
|
@@ -14,6 +14,8 @@ var PlacementType;
|
|
|
14
14
|
PlacementType[PlacementType["MakeChild"] = 2] = "MakeChild";
|
|
15
15
|
PlacementType[PlacementType["Reparent"] = 3] = "Reparent";
|
|
16
16
|
})(PlacementType || (PlacementType = {}));
|
|
17
|
+
const isOrderedDragTarget = (dragTarget) => "childIndex" in dragTarget;
|
|
18
|
+
exports.isOrderedDragTarget = isOrderedDragTarget;
|
|
17
19
|
const canDrop = (dataTransfer, target, tree) => {
|
|
18
20
|
var _a, _b, _c;
|
|
19
21
|
const draggedItems = (_a = tree.getState().dnd) === null || _a === void 0 ? void 0 : _a.draggedItems;
|
|
@@ -8,7 +8,7 @@ const specialKeys = {
|
|
|
8
8
|
Space: /^ $/,
|
|
9
9
|
};
|
|
10
10
|
const testHotkeyMatch = (pressedKeys, tree, hotkey) => {
|
|
11
|
-
const supposedKeys = hotkey.hotkey.split("+");
|
|
11
|
+
const supposedKeys = hotkey.hotkey.toLowerCase().split("+");
|
|
12
12
|
const doKeysMatch = supposedKeys.every((key) => key in specialKeys
|
|
13
13
|
? [...pressedKeys].some((pressedKey) => specialKeys[key].test(pressedKey))
|
|
14
14
|
: pressedKeys.has(key));
|
|
@@ -27,10 +27,10 @@ exports.hotkeysCoreFeature = {
|
|
|
27
27
|
const keydown = (e) => {
|
|
28
28
|
var _a, _b, _c, _d;
|
|
29
29
|
var _e;
|
|
30
|
+
const key = e.key.toLowerCase();
|
|
30
31
|
(_a = (_e = data.current).pressedKeys) !== null && _a !== void 0 ? _a : (_e.pressedKeys = new Set());
|
|
31
|
-
const newMatch = !data.current.pressedKeys.has(
|
|
32
|
-
data.current.pressedKeys.add(
|
|
33
|
-
console.log("HOTKEYS", data.current.pressedKeys);
|
|
32
|
+
const newMatch = !data.current.pressedKeys.has(key);
|
|
33
|
+
data.current.pressedKeys.add(key);
|
|
34
34
|
const hotkeyName = findHotkeyMatch(data.current.pressedKeys, tree, tree.getHotkeyPresets(), tree.getConfig().hotkeys);
|
|
35
35
|
if (!hotkeyName)
|
|
36
36
|
return;
|
|
@@ -51,7 +51,7 @@ exports.hotkeysCoreFeature = {
|
|
|
51
51
|
var _a;
|
|
52
52
|
var _b;
|
|
53
53
|
(_a = (_b = data.current).pressedKeys) !== null && _a !== void 0 ? _a : (_b.pressedKeys = new Set());
|
|
54
|
-
data.current.pressedKeys.delete(e.key);
|
|
54
|
+
data.current.pressedKeys.delete(e.key.toLowerCase());
|
|
55
55
|
};
|
|
56
56
|
// keyup is registered on document, because some hotkeys shift
|
|
57
57
|
// the focus away from the tree (i.e. search)
|
|
@@ -18,8 +18,7 @@ const getNextDragTarget = (tree, isUp, dragTarget) => {
|
|
|
18
18
|
const direction = isUp ? 0 : 1;
|
|
19
19
|
const draggedItems = (_a = tree.getState().dnd) === null || _a === void 0 ? void 0 : _a.draggedItems;
|
|
20
20
|
// currently hovering between items
|
|
21
|
-
if (
|
|
22
|
-
// TODO move check in reusable function
|
|
21
|
+
if ((0, utils_1.isOrderedDragTarget)(dragTarget)) {
|
|
23
22
|
const parent = dragTarget.item.getParent();
|
|
24
23
|
const targetedItem = tree.getItems()[dragTarget.dragLineIndex - 1]; // item above dragline
|
|
25
24
|
const targetCategory = targetedItem
|
|
@@ -77,7 +76,7 @@ const getNextValidDragTarget = (tree, isUp, previousTarget) => {
|
|
|
77
76
|
};
|
|
78
77
|
const updateScroll = (tree) => {
|
|
79
78
|
const state = tree.getState().dnd;
|
|
80
|
-
if (!(state === null || state === void 0 ? void 0 : state.dragTarget) ||
|
|
79
|
+
if (!(state === null || state === void 0 ? void 0 : state.dragTarget) || (0, utils_1.isOrderedDragTarget)(state.dragTarget))
|
|
81
80
|
return;
|
|
82
81
|
state.dragTarget.item.scrollTo({ block: "nearest", inline: "nearest" });
|
|
83
82
|
};
|
|
@@ -118,7 +117,7 @@ const moveDragPosition = (tree, isUp) => {
|
|
|
118
117
|
dragTarget,
|
|
119
118
|
});
|
|
120
119
|
tree.applySubStateUpdate("assistiveDndState", types_1.AssistiveDndState.Dragging);
|
|
121
|
-
if (!(
|
|
120
|
+
if (!(0, utils_1.isOrderedDragTarget)(dragTarget)) {
|
|
122
121
|
dragTarget.item.setFocused();
|
|
123
122
|
}
|
|
124
123
|
updateScroll(tree);
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.propMemoizationFeature = void 0;
|
|
4
|
-
const memoize = (props,
|
|
5
|
-
var _a;
|
|
6
|
-
(_a = dataRef.memoizedProps) !== null && _a !== void 0 ? _a : (dataRef.memoizedProps = {});
|
|
4
|
+
const memoize = (props, memoizedProps) => {
|
|
7
5
|
for (const key in props) {
|
|
8
6
|
if (typeof props[key] === "function") {
|
|
9
|
-
if (key in
|
|
10
|
-
props[key] =
|
|
7
|
+
if (memoizedProps && key in memoizedProps) {
|
|
8
|
+
props[key] = memoizedProps[key];
|
|
11
9
|
}
|
|
12
10
|
else {
|
|
13
|
-
|
|
11
|
+
memoizedProps[key] = props[key];
|
|
14
12
|
}
|
|
15
13
|
}
|
|
16
14
|
}
|
|
@@ -31,18 +29,42 @@ exports.propMemoizationFeature = {
|
|
|
31
29
|
],
|
|
32
30
|
treeInstance: {
|
|
33
31
|
getContainerProps: ({ tree, prev }, treeLabel) => {
|
|
34
|
-
var _a;
|
|
32
|
+
var _a, _b, _c;
|
|
33
|
+
var _d, _e;
|
|
35
34
|
const dataRef = tree.getDataRef();
|
|
36
35
|
const props = (_a = prev === null || prev === void 0 ? void 0 : prev(treeLabel)) !== null && _a !== void 0 ? _a : {};
|
|
37
|
-
|
|
36
|
+
(_b = (_d = dataRef.current).memo) !== null && _b !== void 0 ? _b : (_d.memo = {});
|
|
37
|
+
(_c = (_e = dataRef.current.memo).tree) !== null && _c !== void 0 ? _c : (_e.tree = {});
|
|
38
|
+
return memoize(props, dataRef.current.memo.tree);
|
|
39
|
+
},
|
|
40
|
+
getSearchInputElementProps: ({ tree, prev }) => {
|
|
41
|
+
var _a, _b, _c;
|
|
42
|
+
var _d, _e;
|
|
43
|
+
const dataRef = tree.getDataRef();
|
|
44
|
+
const props = (_a = prev === null || prev === void 0 ? void 0 : prev()) !== null && _a !== void 0 ? _a : {};
|
|
45
|
+
(_b = (_d = dataRef.current).memo) !== null && _b !== void 0 ? _b : (_d.memo = {});
|
|
46
|
+
(_c = (_e = dataRef.current.memo).search) !== null && _c !== void 0 ? _c : (_e.search = {});
|
|
47
|
+
return memoize(props, dataRef.current.memo.search);
|
|
38
48
|
},
|
|
39
49
|
},
|
|
40
50
|
itemInstance: {
|
|
41
51
|
getProps: ({ item, prev }) => {
|
|
42
|
-
var _a;
|
|
52
|
+
var _a, _b, _c;
|
|
53
|
+
var _d, _e;
|
|
54
|
+
const dataRef = item.getDataRef();
|
|
55
|
+
const props = (_a = prev === null || prev === void 0 ? void 0 : prev()) !== null && _a !== void 0 ? _a : {};
|
|
56
|
+
(_b = (_d = dataRef.current).memo) !== null && _b !== void 0 ? _b : (_d.memo = {});
|
|
57
|
+
(_c = (_e = dataRef.current.memo).item) !== null && _c !== void 0 ? _c : (_e.item = {});
|
|
58
|
+
return memoize(props, dataRef.current.memo.item);
|
|
59
|
+
},
|
|
60
|
+
getRenameInputProps: ({ item, prev }) => {
|
|
61
|
+
var _a, _b, _c;
|
|
62
|
+
var _d, _e;
|
|
43
63
|
const dataRef = item.getDataRef();
|
|
44
64
|
const props = (_a = prev === null || prev === void 0 ? void 0 : prev()) !== null && _a !== void 0 ? _a : {};
|
|
45
|
-
|
|
65
|
+
(_b = (_d = dataRef.current).memo) !== null && _b !== void 0 ? _b : (_d.memo = {});
|
|
66
|
+
(_c = (_e = dataRef.current.memo).rename) !== null && _c !== void 0 ? _c : (_e.rename = {});
|
|
67
|
+
return memoize(props, dataRef.current.memo.rename);
|
|
46
68
|
},
|
|
47
69
|
},
|
|
48
70
|
};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export interface PropMemoizationDataRef {
|
|
2
|
-
|
|
2
|
+
memo?: {
|
|
3
|
+
tree?: Record<string, any>;
|
|
4
|
+
item?: Record<string, any>;
|
|
5
|
+
search?: Record<string, any>;
|
|
6
|
+
rename?: Record<string, any>;
|
|
7
|
+
};
|
|
3
8
|
}
|
|
4
9
|
export type PropMemoizationFeatureDef = {
|
|
5
10
|
state: {};
|
|
@@ -28,9 +28,9 @@ exports.selectionFeature = {
|
|
|
28
28
|
const { selectedItems } = tree.getState();
|
|
29
29
|
tree.setSelectedItems(selectedItems.filter((id) => id !== itemId));
|
|
30
30
|
},
|
|
31
|
-
isSelected: ({ tree,
|
|
31
|
+
isSelected: ({ tree, itemId }) => {
|
|
32
32
|
const { selectedItems } = tree.getState();
|
|
33
|
-
return selectedItems.includes(
|
|
33
|
+
return selectedItems.includes(itemId);
|
|
34
34
|
},
|
|
35
35
|
selectUpTo: ({ tree, item }, ctrl) => {
|
|
36
36
|
const indexA = item.getItemMeta().index;
|
|
@@ -11,6 +11,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.treeFeature = void 0;
|
|
13
13
|
const utils_1 = require("../../utils");
|
|
14
|
+
const errors_1 = require("../../utilities/errors");
|
|
14
15
|
exports.treeFeature = {
|
|
15
16
|
key: "tree",
|
|
16
17
|
getInitialState: (initialState) => (Object.assign({ expandedItems: [], focusedItem: null }, initialState)),
|
|
@@ -25,13 +26,17 @@ exports.treeFeature = {
|
|
|
25
26
|
const { expandedItems } = tree.getState();
|
|
26
27
|
const flatItems = [];
|
|
27
28
|
const expandedItemsSet = new Set(expandedItems); // TODO support setting state expandedItems as set instead of array
|
|
28
|
-
const recursiveAdd = (itemId,
|
|
29
|
+
const recursiveAdd = (itemId, path, level, setSize, posInSet) => {
|
|
29
30
|
var _a;
|
|
31
|
+
if (path.includes(itemId)) {
|
|
32
|
+
(0, errors_1.logWarning)(`Circular reference for ${path.join(".")}`);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
30
35
|
flatItems.push({
|
|
31
36
|
itemId,
|
|
32
37
|
level,
|
|
33
38
|
index: flatItems.length,
|
|
34
|
-
parentId,
|
|
39
|
+
parentId: path.at(-1),
|
|
35
40
|
setSize,
|
|
36
41
|
posInSet,
|
|
37
42
|
});
|
|
@@ -39,32 +44,35 @@ exports.treeFeature = {
|
|
|
39
44
|
const children = (_a = tree.retrieveChildrenIds(itemId)) !== null && _a !== void 0 ? _a : [];
|
|
40
45
|
let i = 0;
|
|
41
46
|
for (const childId of children) {
|
|
42
|
-
recursiveAdd(childId, itemId, level + 1, children.length, i++);
|
|
47
|
+
recursiveAdd(childId, path.concat(itemId), level + 1, children.length, i++);
|
|
43
48
|
}
|
|
44
49
|
}
|
|
45
50
|
};
|
|
46
51
|
const children = tree.retrieveChildrenIds(rootItemId);
|
|
47
52
|
let i = 0;
|
|
48
53
|
for (const itemId of children) {
|
|
49
|
-
recursiveAdd(itemId, rootItemId, 0, children.length, i++);
|
|
54
|
+
recursiveAdd(itemId, [rootItemId], 0, children.length, i++);
|
|
50
55
|
}
|
|
51
56
|
return flatItems;
|
|
52
57
|
},
|
|
53
|
-
// TODO memo
|
|
54
58
|
getFocusedItem: ({ tree }) => {
|
|
55
59
|
var _a, _b;
|
|
56
60
|
return ((_b = tree.getItemInstance((_a = tree.getState().focusedItem) !== null && _a !== void 0 ? _a : "")) !== null && _b !== void 0 ? _b : tree.getItems()[0]);
|
|
57
61
|
},
|
|
58
62
|
focusNextItem: ({ tree }) => {
|
|
59
63
|
var _a;
|
|
60
|
-
const
|
|
61
|
-
|
|
64
|
+
const focused = tree.getFocusedItem().getItemMeta();
|
|
65
|
+
if (!focused)
|
|
66
|
+
return;
|
|
67
|
+
const nextIndex = Math.min(focused.index + 1, tree.getItems().length - 1);
|
|
62
68
|
(_a = tree.getItems()[nextIndex]) === null || _a === void 0 ? void 0 : _a.setFocused();
|
|
63
69
|
},
|
|
64
70
|
focusPreviousItem: ({ tree }) => {
|
|
65
71
|
var _a;
|
|
66
|
-
const
|
|
67
|
-
|
|
72
|
+
const focused = tree.getFocusedItem().getItemMeta();
|
|
73
|
+
if (!focused)
|
|
74
|
+
return;
|
|
75
|
+
const nextIndex = Math.max(focused.index - 1, 0);
|
|
68
76
|
(_a = tree.getItems()[nextIndex]) === null || _a === void 0 ? void 0 : _a.setFocused();
|
|
69
77
|
},
|
|
70
78
|
updateDomFocus: ({ tree }) => {
|
|
@@ -80,7 +88,6 @@ exports.treeFeature = {
|
|
|
80
88
|
focusedElement.focus();
|
|
81
89
|
}));
|
|
82
90
|
},
|
|
83
|
-
// TODO add label parameter
|
|
84
91
|
getContainerProps: ({ prev, tree }, treeLabel) => (Object.assign(Object.assign({}, prev === null || prev === void 0 ? void 0 : prev()), { role: "tree", "aria-label": treeLabel !== null && treeLabel !== void 0 ? treeLabel : "", ref: tree.registerElement })),
|
|
85
92
|
// relevant for hotkeys of this feature
|
|
86
93
|
isSearchOpen: () => false,
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.throwError = void 0;
|
|
4
|
-
const
|
|
3
|
+
exports.logWarning = exports.throwError = void 0;
|
|
4
|
+
const prefix = "Headless Tree: ";
|
|
5
|
+
const throwError = (message) => Error(prefix + message);
|
|
5
6
|
exports.throwError = throwError;
|
|
7
|
+
// eslint-disable-next-line no-console
|
|
8
|
+
const logWarning = (message) => console.warn(prefix + message);
|
|
9
|
+
exports.logWarning = logWarning;
|
|
@@ -7,7 +7,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
7
7
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
|
-
import { canDrop, getDragCode, getDragTarget } from "./utils";
|
|
10
|
+
import { canDrop, getDragCode, getDragTarget, isOrderedDragTarget, } from "./utils";
|
|
11
11
|
import { makeStateUpdater } from "../../utils";
|
|
12
12
|
export const dragAndDropFeature = {
|
|
13
13
|
key: "drag-and-drop",
|
|
@@ -26,7 +26,7 @@ export const dragAndDropFeature = {
|
|
|
26
26
|
const target = tree.getDragTarget();
|
|
27
27
|
const indent = ((_a = target === null || target === void 0 ? void 0 : target.item.getItemMeta().level) !== null && _a !== void 0 ? _a : 0) + 1;
|
|
28
28
|
const treeBb = (_b = tree.getElement()) === null || _b === void 0 ? void 0 : _b.getBoundingClientRect();
|
|
29
|
-
if (!target || !treeBb || !(
|
|
29
|
+
if (!target || !treeBb || !isOrderedDragTarget(target))
|
|
30
30
|
return null;
|
|
31
31
|
const leftOffset = target.dragLineLevel * ((_c = tree.getConfig().indent) !== null && _c !== void 0 ? _c : 1);
|
|
32
32
|
const targetItem = tree.getItems()[target.dragLineIndex];
|
|
@@ -154,7 +154,7 @@ export const dragAndDropFeature = {
|
|
|
154
154
|
isDragTargetAbove: ({ tree, item }) => {
|
|
155
155
|
const target = tree.getDragTarget();
|
|
156
156
|
if (!target ||
|
|
157
|
-
!(
|
|
157
|
+
!isOrderedDragTarget(target) ||
|
|
158
158
|
target.item !== item.getParent())
|
|
159
159
|
return false;
|
|
160
160
|
return target.childIndex === item.getItemMeta().posInSet;
|
|
@@ -162,7 +162,7 @@ export const dragAndDropFeature = {
|
|
|
162
162
|
isDragTargetBelow: ({ tree, item }) => {
|
|
163
163
|
const target = tree.getDragTarget();
|
|
164
164
|
if (!target ||
|
|
165
|
-
!(
|
|
165
|
+
!isOrderedDragTarget(target) ||
|
|
166
166
|
target.item !== item.getParent())
|
|
167
167
|
return false;
|
|
168
168
|
return target.childIndex - 1 === item.getItemMeta().posInSet;
|
|
@@ -5,6 +5,13 @@ export declare enum ItemDropCategory {
|
|
|
5
5
|
ExpandedFolder = 1,
|
|
6
6
|
LastInGroup = 2
|
|
7
7
|
}
|
|
8
|
+
export declare const isOrderedDragTarget: <T>(dragTarget: DragTarget<T>) => dragTarget is {
|
|
9
|
+
item: ItemInstance<T>;
|
|
10
|
+
childIndex: number;
|
|
11
|
+
insertionIndex: number;
|
|
12
|
+
dragLineIndex: number;
|
|
13
|
+
dragLineLevel: number;
|
|
14
|
+
};
|
|
8
15
|
export declare const canDrop: (dataTransfer: DataTransfer | null, target: DragTarget<any>, tree: TreeInstance<any>) => boolean;
|
|
9
16
|
export declare const getItemDropCategory: (item: ItemInstance<any>) => ItemDropCategory;
|
|
10
17
|
export declare const getInsertionIndex: <T>(children: ItemInstance<T>[], childIndex: number, draggedItems: ItemInstance<T>[] | undefined) => number;
|
|
@@ -11,6 +11,7 @@ var PlacementType;
|
|
|
11
11
|
PlacementType[PlacementType["MakeChild"] = 2] = "MakeChild";
|
|
12
12
|
PlacementType[PlacementType["Reparent"] = 3] = "Reparent";
|
|
13
13
|
})(PlacementType || (PlacementType = {}));
|
|
14
|
+
export const isOrderedDragTarget = (dragTarget) => "childIndex" in dragTarget;
|
|
14
15
|
export const canDrop = (dataTransfer, target, tree) => {
|
|
15
16
|
var _a, _b, _c;
|
|
16
17
|
const draggedItems = (_a = tree.getState().dnd) === null || _a === void 0 ? void 0 : _a.draggedItems;
|
|
@@ -5,7 +5,7 @@ const specialKeys = {
|
|
|
5
5
|
Space: /^ $/,
|
|
6
6
|
};
|
|
7
7
|
const testHotkeyMatch = (pressedKeys, tree, hotkey) => {
|
|
8
|
-
const supposedKeys = hotkey.hotkey.split("+");
|
|
8
|
+
const supposedKeys = hotkey.hotkey.toLowerCase().split("+");
|
|
9
9
|
const doKeysMatch = supposedKeys.every((key) => key in specialKeys
|
|
10
10
|
? [...pressedKeys].some((pressedKey) => specialKeys[key].test(pressedKey))
|
|
11
11
|
: pressedKeys.has(key));
|
|
@@ -24,10 +24,10 @@ export const hotkeysCoreFeature = {
|
|
|
24
24
|
const keydown = (e) => {
|
|
25
25
|
var _a, _b, _c, _d;
|
|
26
26
|
var _e;
|
|
27
|
+
const key = e.key.toLowerCase();
|
|
27
28
|
(_a = (_e = data.current).pressedKeys) !== null && _a !== void 0 ? _a : (_e.pressedKeys = new Set());
|
|
28
|
-
const newMatch = !data.current.pressedKeys.has(
|
|
29
|
-
data.current.pressedKeys.add(
|
|
30
|
-
console.log("HOTKEYS", data.current.pressedKeys);
|
|
29
|
+
const newMatch = !data.current.pressedKeys.has(key);
|
|
30
|
+
data.current.pressedKeys.add(key);
|
|
31
31
|
const hotkeyName = findHotkeyMatch(data.current.pressedKeys, tree, tree.getHotkeyPresets(), tree.getConfig().hotkeys);
|
|
32
32
|
if (!hotkeyName)
|
|
33
33
|
return;
|
|
@@ -48,7 +48,7 @@ export const hotkeysCoreFeature = {
|
|
|
48
48
|
var _a;
|
|
49
49
|
var _b;
|
|
50
50
|
(_a = (_b = data.current).pressedKeys) !== null && _a !== void 0 ? _a : (_b.pressedKeys = new Set());
|
|
51
|
-
data.current.pressedKeys.delete(e.key);
|
|
51
|
+
data.current.pressedKeys.delete(e.key.toLowerCase());
|
|
52
52
|
};
|
|
53
53
|
// keyup is registered on document, because some hotkeys shift
|
|
54
54
|
// the focus away from the tree (i.e. search)
|
|
@@ -7,7 +7,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
7
7
|
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
|
-
import { ItemDropCategory, canDrop, getInsertionIndex, getItemDropCategory, getReparentTarget, } from "../drag-and-drop/utils";
|
|
10
|
+
import { ItemDropCategory, canDrop, getInsertionIndex, getItemDropCategory, getReparentTarget, isOrderedDragTarget, } from "../drag-and-drop/utils";
|
|
11
11
|
import { makeStateUpdater } from "../../utils";
|
|
12
12
|
import { AssistiveDndState } from "./types";
|
|
13
13
|
const getNextDragTarget = (tree, isUp, dragTarget) => {
|
|
@@ -15,8 +15,7 @@ const getNextDragTarget = (tree, isUp, dragTarget) => {
|
|
|
15
15
|
const direction = isUp ? 0 : 1;
|
|
16
16
|
const draggedItems = (_a = tree.getState().dnd) === null || _a === void 0 ? void 0 : _a.draggedItems;
|
|
17
17
|
// currently hovering between items
|
|
18
|
-
if (
|
|
19
|
-
// TODO move check in reusable function
|
|
18
|
+
if (isOrderedDragTarget(dragTarget)) {
|
|
20
19
|
const parent = dragTarget.item.getParent();
|
|
21
20
|
const targetedItem = tree.getItems()[dragTarget.dragLineIndex - 1]; // item above dragline
|
|
22
21
|
const targetCategory = targetedItem
|
|
@@ -74,7 +73,7 @@ const getNextValidDragTarget = (tree, isUp, previousTarget) => {
|
|
|
74
73
|
};
|
|
75
74
|
const updateScroll = (tree) => {
|
|
76
75
|
const state = tree.getState().dnd;
|
|
77
|
-
if (!(state === null || state === void 0 ? void 0 : state.dragTarget) ||
|
|
76
|
+
if (!(state === null || state === void 0 ? void 0 : state.dragTarget) || isOrderedDragTarget(state.dragTarget))
|
|
78
77
|
return;
|
|
79
78
|
state.dragTarget.item.scrollTo({ block: "nearest", inline: "nearest" });
|
|
80
79
|
};
|
|
@@ -115,7 +114,7 @@ const moveDragPosition = (tree, isUp) => {
|
|
|
115
114
|
dragTarget,
|
|
116
115
|
});
|
|
117
116
|
tree.applySubStateUpdate("assistiveDndState", AssistiveDndState.Dragging);
|
|
118
|
-
if (!(
|
|
117
|
+
if (!isOrderedDragTarget(dragTarget)) {
|
|
119
118
|
dragTarget.item.setFocused();
|
|
120
119
|
}
|
|
121
120
|
updateScroll(tree);
|
|
@@ -1,13 +1,11 @@
|
|
|
1
|
-
const memoize = (props,
|
|
2
|
-
var _a;
|
|
3
|
-
(_a = dataRef.memoizedProps) !== null && _a !== void 0 ? _a : (dataRef.memoizedProps = {});
|
|
1
|
+
const memoize = (props, memoizedProps) => {
|
|
4
2
|
for (const key in props) {
|
|
5
3
|
if (typeof props[key] === "function") {
|
|
6
|
-
if (key in
|
|
7
|
-
props[key] =
|
|
4
|
+
if (memoizedProps && key in memoizedProps) {
|
|
5
|
+
props[key] = memoizedProps[key];
|
|
8
6
|
}
|
|
9
7
|
else {
|
|
10
|
-
|
|
8
|
+
memoizedProps[key] = props[key];
|
|
11
9
|
}
|
|
12
10
|
}
|
|
13
11
|
}
|
|
@@ -28,18 +26,42 @@ export const propMemoizationFeature = {
|
|
|
28
26
|
],
|
|
29
27
|
treeInstance: {
|
|
30
28
|
getContainerProps: ({ tree, prev }, treeLabel) => {
|
|
31
|
-
var _a;
|
|
29
|
+
var _a, _b, _c;
|
|
30
|
+
var _d, _e;
|
|
32
31
|
const dataRef = tree.getDataRef();
|
|
33
32
|
const props = (_a = prev === null || prev === void 0 ? void 0 : prev(treeLabel)) !== null && _a !== void 0 ? _a : {};
|
|
34
|
-
|
|
33
|
+
(_b = (_d = dataRef.current).memo) !== null && _b !== void 0 ? _b : (_d.memo = {});
|
|
34
|
+
(_c = (_e = dataRef.current.memo).tree) !== null && _c !== void 0 ? _c : (_e.tree = {});
|
|
35
|
+
return memoize(props, dataRef.current.memo.tree);
|
|
36
|
+
},
|
|
37
|
+
getSearchInputElementProps: ({ tree, prev }) => {
|
|
38
|
+
var _a, _b, _c;
|
|
39
|
+
var _d, _e;
|
|
40
|
+
const dataRef = tree.getDataRef();
|
|
41
|
+
const props = (_a = prev === null || prev === void 0 ? void 0 : prev()) !== null && _a !== void 0 ? _a : {};
|
|
42
|
+
(_b = (_d = dataRef.current).memo) !== null && _b !== void 0 ? _b : (_d.memo = {});
|
|
43
|
+
(_c = (_e = dataRef.current.memo).search) !== null && _c !== void 0 ? _c : (_e.search = {});
|
|
44
|
+
return memoize(props, dataRef.current.memo.search);
|
|
35
45
|
},
|
|
36
46
|
},
|
|
37
47
|
itemInstance: {
|
|
38
48
|
getProps: ({ item, prev }) => {
|
|
39
|
-
var _a;
|
|
49
|
+
var _a, _b, _c;
|
|
50
|
+
var _d, _e;
|
|
51
|
+
const dataRef = item.getDataRef();
|
|
52
|
+
const props = (_a = prev === null || prev === void 0 ? void 0 : prev()) !== null && _a !== void 0 ? _a : {};
|
|
53
|
+
(_b = (_d = dataRef.current).memo) !== null && _b !== void 0 ? _b : (_d.memo = {});
|
|
54
|
+
(_c = (_e = dataRef.current.memo).item) !== null && _c !== void 0 ? _c : (_e.item = {});
|
|
55
|
+
return memoize(props, dataRef.current.memo.item);
|
|
56
|
+
},
|
|
57
|
+
getRenameInputProps: ({ item, prev }) => {
|
|
58
|
+
var _a, _b, _c;
|
|
59
|
+
var _d, _e;
|
|
40
60
|
const dataRef = item.getDataRef();
|
|
41
61
|
const props = (_a = prev === null || prev === void 0 ? void 0 : prev()) !== null && _a !== void 0 ? _a : {};
|
|
42
|
-
|
|
62
|
+
(_b = (_d = dataRef.current).memo) !== null && _b !== void 0 ? _b : (_d.memo = {});
|
|
63
|
+
(_c = (_e = dataRef.current.memo).rename) !== null && _c !== void 0 ? _c : (_e.rename = {});
|
|
64
|
+
return memoize(props, dataRef.current.memo.rename);
|
|
43
65
|
},
|
|
44
66
|
},
|
|
45
67
|
};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export interface PropMemoizationDataRef {
|
|
2
|
-
|
|
2
|
+
memo?: {
|
|
3
|
+
tree?: Record<string, any>;
|
|
4
|
+
item?: Record<string, any>;
|
|
5
|
+
search?: Record<string, any>;
|
|
6
|
+
rename?: Record<string, any>;
|
|
7
|
+
};
|
|
3
8
|
}
|
|
4
9
|
export type PropMemoizationFeatureDef = {
|
|
5
10
|
state: {};
|
|
@@ -25,9 +25,9 @@ export const selectionFeature = {
|
|
|
25
25
|
const { selectedItems } = tree.getState();
|
|
26
26
|
tree.setSelectedItems(selectedItems.filter((id) => id !== itemId));
|
|
27
27
|
},
|
|
28
|
-
isSelected: ({ tree,
|
|
28
|
+
isSelected: ({ tree, itemId }) => {
|
|
29
29
|
const { selectedItems } = tree.getState();
|
|
30
|
-
return selectedItems.includes(
|
|
30
|
+
return selectedItems.includes(itemId);
|
|
31
31
|
},
|
|
32
32
|
selectUpTo: ({ tree, item }, ctrl) => {
|
|
33
33
|
const indexA = item.getItemMeta().index;
|
|
@@ -8,6 +8,7 @@ var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, ge
|
|
|
8
8
|
});
|
|
9
9
|
};
|
|
10
10
|
import { makeStateUpdater, poll } from "../../utils";
|
|
11
|
+
import { logWarning } from "../../utilities/errors";
|
|
11
12
|
export const treeFeature = {
|
|
12
13
|
key: "tree",
|
|
13
14
|
getInitialState: (initialState) => (Object.assign({ expandedItems: [], focusedItem: null }, initialState)),
|
|
@@ -22,13 +23,17 @@ export const treeFeature = {
|
|
|
22
23
|
const { expandedItems } = tree.getState();
|
|
23
24
|
const flatItems = [];
|
|
24
25
|
const expandedItemsSet = new Set(expandedItems); // TODO support setting state expandedItems as set instead of array
|
|
25
|
-
const recursiveAdd = (itemId,
|
|
26
|
+
const recursiveAdd = (itemId, path, level, setSize, posInSet) => {
|
|
26
27
|
var _a;
|
|
28
|
+
if (path.includes(itemId)) {
|
|
29
|
+
logWarning(`Circular reference for ${path.join(".")}`);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
27
32
|
flatItems.push({
|
|
28
33
|
itemId,
|
|
29
34
|
level,
|
|
30
35
|
index: flatItems.length,
|
|
31
|
-
parentId,
|
|
36
|
+
parentId: path.at(-1),
|
|
32
37
|
setSize,
|
|
33
38
|
posInSet,
|
|
34
39
|
});
|
|
@@ -36,32 +41,35 @@ export const treeFeature = {
|
|
|
36
41
|
const children = (_a = tree.retrieveChildrenIds(itemId)) !== null && _a !== void 0 ? _a : [];
|
|
37
42
|
let i = 0;
|
|
38
43
|
for (const childId of children) {
|
|
39
|
-
recursiveAdd(childId, itemId, level + 1, children.length, i++);
|
|
44
|
+
recursiveAdd(childId, path.concat(itemId), level + 1, children.length, i++);
|
|
40
45
|
}
|
|
41
46
|
}
|
|
42
47
|
};
|
|
43
48
|
const children = tree.retrieveChildrenIds(rootItemId);
|
|
44
49
|
let i = 0;
|
|
45
50
|
for (const itemId of children) {
|
|
46
|
-
recursiveAdd(itemId, rootItemId, 0, children.length, i++);
|
|
51
|
+
recursiveAdd(itemId, [rootItemId], 0, children.length, i++);
|
|
47
52
|
}
|
|
48
53
|
return flatItems;
|
|
49
54
|
},
|
|
50
|
-
// TODO memo
|
|
51
55
|
getFocusedItem: ({ tree }) => {
|
|
52
56
|
var _a, _b;
|
|
53
57
|
return ((_b = tree.getItemInstance((_a = tree.getState().focusedItem) !== null && _a !== void 0 ? _a : "")) !== null && _b !== void 0 ? _b : tree.getItems()[0]);
|
|
54
58
|
},
|
|
55
59
|
focusNextItem: ({ tree }) => {
|
|
56
60
|
var _a;
|
|
57
|
-
const
|
|
58
|
-
|
|
61
|
+
const focused = tree.getFocusedItem().getItemMeta();
|
|
62
|
+
if (!focused)
|
|
63
|
+
return;
|
|
64
|
+
const nextIndex = Math.min(focused.index + 1, tree.getItems().length - 1);
|
|
59
65
|
(_a = tree.getItems()[nextIndex]) === null || _a === void 0 ? void 0 : _a.setFocused();
|
|
60
66
|
},
|
|
61
67
|
focusPreviousItem: ({ tree }) => {
|
|
62
68
|
var _a;
|
|
63
|
-
const
|
|
64
|
-
|
|
69
|
+
const focused = tree.getFocusedItem().getItemMeta();
|
|
70
|
+
if (!focused)
|
|
71
|
+
return;
|
|
72
|
+
const nextIndex = Math.max(focused.index - 1, 0);
|
|
65
73
|
(_a = tree.getItems()[nextIndex]) === null || _a === void 0 ? void 0 : _a.setFocused();
|
|
66
74
|
},
|
|
67
75
|
updateDomFocus: ({ tree }) => {
|
|
@@ -77,7 +85,6 @@ export const treeFeature = {
|
|
|
77
85
|
focusedElement.focus();
|
|
78
86
|
}));
|
|
79
87
|
},
|
|
80
|
-
// TODO add label parameter
|
|
81
88
|
getContainerProps: ({ prev, tree }, treeLabel) => (Object.assign(Object.assign({}, prev === null || prev === void 0 ? void 0 : prev()), { role: "tree", "aria-label": treeLabel !== null && treeLabel !== void 0 ? treeLabel : "", ref: tree.registerElement })),
|
|
82
89
|
// relevant for hotkeys of this feature
|
|
83
90
|
isSearchOpen: () => false,
|
|
@@ -1 +1,4 @@
|
|
|
1
|
-
|
|
1
|
+
const prefix = "Headless Tree: ";
|
|
2
|
+
export const throwError = (message) => Error(prefix + message);
|
|
3
|
+
// eslint-disable-next-line no-console
|
|
4
|
+
export const logWarning = (message) => console.warn(prefix + message);
|
package/package.json
CHANGED
package/readme.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
[](https://headless-tree.lukasbach.com/)
|
|
4
|
+
[](https://discord.gg/KuZ6EezzVw)
|
|
5
|
+
[](https://bsky.app/profile/lukasbach.bsky.social)
|
|
6
|
+
[](https://github.com/sponsors/lukasbach)
|
|
7
|
+
[](https://github.com/lukasbach)
|
|
8
|
+
[](https://www.npmjs.com/package/@headless-tree/core)
|
|
9
|
+
[](https://www.npmjs.com/package/@headless-tree/react)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
Super-easy integration of complex tree components into React. Supports ordered
|
|
13
|
+
and unordered drag-and-drop, extensive keybindings, search, renaming and more.
|
|
14
|
+
Fully customizable and accessible. Headless Tree is the official successor for
|
|
15
|
+
[react-complex-tree](https://github.com/lukasbach/react-complex-tree).
|
|
16
|
+
|
|
17
|
+
It aims to bring the many features of complex tree views, like multi-select,
|
|
18
|
+
drag-and-drop, keyboard navigation, tree search, renaming and more, while
|
|
19
|
+
being unopinionated about the styling and rendering of the tree itself.
|
|
20
|
+
Accessibility is ensured by default, and the integration is extremely
|
|
21
|
+
simple and flexible.
|
|
22
|
+
|
|
23
|
+
The interface gives you a flat list of tree nodes
|
|
24
|
+
that you can easily render yourself, which keeps the complexity of the
|
|
25
|
+
code low and allows you to customize the tree to your needs. This flat
|
|
26
|
+
structure also allows you to virtualize the tree with any virtualization
|
|
27
|
+
library you want. The library automatically provides the necessary
|
|
28
|
+
aria tags to emulate a nested tree structure, so that accessibility
|
|
29
|
+
requirements are met despite the flat structure.
|
|
30
|
+
|
|
31
|
+
Dive into [the Get Started page](https://headless-tree.lukasbach.com/getstarted)
|
|
32
|
+
to find out how to use Headless Tree, or have a look at
|
|
33
|
+
[the samples on the Headless Tree Homepage](https://headless-tree.lukasbach.com/#demogrid)
|
|
34
|
+
to get an idea of what you can do with it.
|
|
35
|
+
|
|
36
|
+
> [!TIP]
|
|
37
|
+
> Headless Tree is now available as Beta! The library is mostly stable and
|
|
38
|
+
> production ready, and will be generally released within two months, once
|
|
39
|
+
> I have collected feedback and fixed any bugs that might arise. I've written
|
|
40
|
+
> [a blog post](https://medium.com/@lukasbach/headless-tree-and-the-future-of-react-complex-tree-fc920700e82a)
|
|
41
|
+
> about the details of the change, and the future of the library.
|
|
42
|
+
>
|
|
43
|
+
> Join
|
|
44
|
+
> [the Discord](https://discord.gg/KuZ6EezzVw) to get involved, and
|
|
45
|
+
> [follow on Bluesky](https://bsky.app/profile/lukasbach.bsky.social) to
|
|
46
|
+
> stay up to date.
|
|
47
|
+
|
|
48
|
+
## Features
|
|
49
|
+
|
|
50
|
+
- [Simple Interface](https://headless-tree.lukasbach.com/?demo=0#demogrid): Easy integration in React with full customizability of DOM
|
|
51
|
+
- [Drag and Drop](https://headless-tree.lukasbach.com/?demo=1#demogrid): Powerful ordered drag-and-drop, that can interact with external drag events
|
|
52
|
+
- [Scalable](https://headless-tree.lukasbach.com/?demo=2#demogrid): Headless Tree remains performant even with large trees
|
|
53
|
+
- [Virtualization Support](https://headless-tree.lukasbach.com/?demo=3#demogrid): Compatible with common virtualization library to support 100k+ items
|
|
54
|
+
- [Hotkeys!](https://headless-tree.lukasbach.com/?demo=4#demogrid): Lots of hotkeys, fully customizable
|
|
55
|
+
- [Search Support](https://headless-tree.lukasbach.com/?demo=5#demogrid): Typeahead anywhere in the tree to quickly search the entire tree
|
|
56
|
+
- [Rename items](https://headless-tree.lukasbach.com/?demo=6#demogrid): Optionally allow users to rename items inside the tree
|
|
57
|
+
- [Manage State](https://headless-tree.lukasbach.com/?demo=7#demogrid): Let Headless Tree manage tree state internally, or manage any part of it yourself
|
|
58
|
+
- [Customize Behavior](https://headless-tree.lukasbach.com/?demo=8#demogrid): Easily overwrite internal behavior like requiring double clicks on items to expand
|
|
59
|
+
- [Customize Logic](https://headless-tree.lukasbach.com/?demo=9#demogrid): Overwrite or expand any internal behavior of Headless Tree
|
|
60
|
+
- [Async Data Support](https://headless-tree.lukasbach.com/?demo=10#demogrid): Use synchronous or asynchronous data sources for your tree. Headless Tree comes with optional caching for async data
|
|
61
|
+
- Free of dependencies
|
|
62
|
+
- Or check out [this comprehensive playground](https://headless-tree.lukasbach.com/?demo=11#demogrid) that has most of the capabilities enabled.
|
|
63
|
+
|
|
64
|
+
## Bundle Size
|
|
65
|
+
|
|
66
|
+
Headless Tree exports individual features in a tree-shaking-friendly
|
|
67
|
+
way, allowing you to only include what you need to keep your bundle size
|
|
68
|
+
small. Listed bundle sizes are based on min+gzipped bundles, and are
|
|
69
|
+
based on the Bundlephobia report as of Headless Tree v0.0.15.
|
|
70
|
+
|
|
71
|
+
| Feature | Bundle Size |
|
|
72
|
+
|------------------------|-------------|
|
|
73
|
+
| Tree Core | 3.1kB |
|
|
74
|
+
| Sync Data Loader | 0.8kB |
|
|
75
|
+
| Async Data Loader | 1.4kB |
|
|
76
|
+
| Selections | 1.1kB |
|
|
77
|
+
| Drag and Drop | 2.8kB |
|
|
78
|
+
| Keyboard Drag and Drop | 2.7kB |
|
|
79
|
+
| Hotkeys | 0.8kB |
|
|
80
|
+
| Tree Search | 1.3kB |
|
|
81
|
+
| Renaming | 0.9kB |
|
|
82
|
+
| Expand All | 0.7kB |
|
|
83
|
+
| React Bindings | 0.4kB |
|
|
84
|
+
|
|
85
|
+
Total bundle size is 9.5kB plus 0.4kB for the React bindings. Note that
|
|
86
|
+
the sum of features is bigger than the total bundle size, because several
|
|
87
|
+
features share code. Tree-shaking will ensure that the minimum amount of
|
|
88
|
+
code is included in your bundle.
|
|
89
|
+
|
|
90
|
+
## Get Started
|
|
91
|
+
|
|
92
|
+
> [!TIP]
|
|
93
|
+
> You can find a comprehensive [get-started guide](https://headless-tree.lukasbach.com/getstarted)
|
|
94
|
+
> on the documentation homepage. The following gives a brief overview.
|
|
95
|
+
|
|
96
|
+
Install Headless Tree via npm:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
npm install @headless-tree/core @headless-tree/react
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
In your react component, call the `useTree` hook from `@headless-tree/react` with the configuration of
|
|
103
|
+
your tree:
|
|
104
|
+
|
|
105
|
+
```tsx
|
|
106
|
+
import {
|
|
107
|
+
hotkeysCoreFeature,
|
|
108
|
+
selectionFeature,
|
|
109
|
+
syncDataLoaderFeature,
|
|
110
|
+
} from "@headless-tree/core";
|
|
111
|
+
import { useTree } from "@headless-tree/react";
|
|
112
|
+
|
|
113
|
+
const tree = useTree<string>({
|
|
114
|
+
initialState: { expandedItems: ["folder-1"] },
|
|
115
|
+
rootItemId: "folder",
|
|
116
|
+
getItemName: (item) => item.getItemData(),
|
|
117
|
+
isItemFolder: (item) => !item.getItemData().endsWith("item"),
|
|
118
|
+
dataLoader: {
|
|
119
|
+
getItem: (itemId) => itemId,
|
|
120
|
+
getChildren: (itemId) => [
|
|
121
|
+
`${itemId}-folder`,
|
|
122
|
+
`${itemId}-1-item`,
|
|
123
|
+
`${itemId}-2-item`,
|
|
124
|
+
],
|
|
125
|
+
},
|
|
126
|
+
indent: 20,
|
|
127
|
+
features: [syncDataLoaderFeature, selectionFeature, hotkeysCoreFeature],
|
|
128
|
+
});
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Then, render your tree based on the tree instance returned from the hook:
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
<div {...tree.getContainerProps()} className="tree">
|
|
135
|
+
{tree.getItems().map((item) => (
|
|
136
|
+
<button
|
|
137
|
+
{...item.getProps()}
|
|
138
|
+
key={item.getId()}
|
|
139
|
+
style={{ paddingLeft: `${item.getItemMeta().level * 20}px` }}
|
|
140
|
+
>
|
|
141
|
+
<div
|
|
142
|
+
className={cx("treeitem", {
|
|
143
|
+
focused: item.isFocused(),
|
|
144
|
+
expanded: item.isExpanded(),
|
|
145
|
+
selected: item.isSelected(),
|
|
146
|
+
folder: item.isFolder(),
|
|
147
|
+
})}
|
|
148
|
+
>
|
|
149
|
+
{item.getItemName()}
|
|
150
|
+
</div>
|
|
151
|
+
</button>
|
|
152
|
+
))}
|
|
153
|
+
</div>
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Read on in the [get started guide](https://headless-tree.lukasbach.com/getstarted) to learn more about
|
|
157
|
+
how to use Headless Tree, and how to customize it to your needs.
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { FeatureImplementation } from "../../types/core";
|
|
2
2
|
import { DndDataRef, DragLineData } from "./types";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
canDrop,
|
|
5
|
+
getDragCode,
|
|
6
|
+
getDragTarget,
|
|
7
|
+
isOrderedDragTarget,
|
|
8
|
+
} from "./utils";
|
|
4
9
|
import { makeStateUpdater } from "../../utils";
|
|
5
10
|
|
|
6
11
|
export const dragAndDropFeature: FeatureImplementation = {
|
|
@@ -30,7 +35,7 @@ export const dragAndDropFeature: FeatureImplementation = {
|
|
|
30
35
|
|
|
31
36
|
const treeBb = tree.getElement()?.getBoundingClientRect();
|
|
32
37
|
|
|
33
|
-
if (!target || !treeBb || !(
|
|
38
|
+
if (!target || !treeBb || !isOrderedDragTarget(target)) return null;
|
|
34
39
|
|
|
35
40
|
const leftOffset = target.dragLineLevel * (tree.getConfig().indent ?? 1);
|
|
36
41
|
const targetItem = tree.getItems()[target.dragLineIndex];
|
|
@@ -212,7 +217,7 @@ export const dragAndDropFeature: FeatureImplementation = {
|
|
|
212
217
|
|
|
213
218
|
if (
|
|
214
219
|
!target ||
|
|
215
|
-
!(
|
|
220
|
+
!isOrderedDragTarget(target) ||
|
|
216
221
|
target.item !== item.getParent()
|
|
217
222
|
)
|
|
218
223
|
return false;
|
|
@@ -224,7 +229,7 @@ export const dragAndDropFeature: FeatureImplementation = {
|
|
|
224
229
|
|
|
225
230
|
if (
|
|
226
231
|
!target ||
|
|
227
|
-
!(
|
|
232
|
+
!isOrderedDragTarget(target) ||
|
|
228
233
|
target.item !== item.getParent()
|
|
229
234
|
)
|
|
230
235
|
return false;
|
|
@@ -17,7 +17,7 @@ const testHotkeyMatch = (
|
|
|
17
17
|
tree: TreeInstance<any>,
|
|
18
18
|
hotkey: HotkeyConfig<any>,
|
|
19
19
|
) => {
|
|
20
|
-
const supposedKeys = hotkey.hotkey.split("+");
|
|
20
|
+
const supposedKeys = hotkey.hotkey.toLowerCase().split("+");
|
|
21
21
|
const doKeysMatch = supposedKeys.every((key) =>
|
|
22
22
|
key in specialKeys
|
|
23
23
|
? [...pressedKeys].some((pressedKey) => specialKeys[key].test(pressedKey))
|
|
@@ -45,10 +45,10 @@ export const hotkeysCoreFeature: FeatureImplementation = {
|
|
|
45
45
|
onTreeMount: (tree, element) => {
|
|
46
46
|
const data = tree.getDataRef<HotkeysCoreDataRef>();
|
|
47
47
|
const keydown = (e: KeyboardEvent) => {
|
|
48
|
+
const key = e.key.toLowerCase();
|
|
48
49
|
data.current.pressedKeys ??= new Set();
|
|
49
|
-
const newMatch = !data.current.pressedKeys.has(
|
|
50
|
-
data.current.pressedKeys.add(
|
|
51
|
-
console.log("HOTKEYS", data.current.pressedKeys);
|
|
50
|
+
const newMatch = !data.current.pressedKeys.has(key);
|
|
51
|
+
data.current.pressedKeys.add(key);
|
|
52
52
|
|
|
53
53
|
const hotkeyName = findHotkeyMatch(
|
|
54
54
|
data.current.pressedKeys,
|
|
@@ -79,7 +79,7 @@ export const hotkeysCoreFeature: FeatureImplementation = {
|
|
|
79
79
|
|
|
80
80
|
const keyup = (e: KeyboardEvent) => {
|
|
81
81
|
data.current.pressedKeys ??= new Set();
|
|
82
|
-
data.current.pressedKeys.delete(e.key);
|
|
82
|
+
data.current.pressedKeys.delete(e.key.toLowerCase());
|
|
83
83
|
};
|
|
84
84
|
|
|
85
85
|
// keyup is registered on document, because some hotkeys shift
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
getInsertionIndex,
|
|
11
11
|
getItemDropCategory,
|
|
12
12
|
getReparentTarget,
|
|
13
|
+
isOrderedDragTarget,
|
|
13
14
|
} from "../drag-and-drop/utils";
|
|
14
15
|
import { makeStateUpdater } from "../../utils";
|
|
15
16
|
import { AssistiveDndState, KDndDataRef } from "./types";
|
|
@@ -23,8 +24,7 @@ const getNextDragTarget = <T>(
|
|
|
23
24
|
const draggedItems = tree.getState().dnd?.draggedItems;
|
|
24
25
|
|
|
25
26
|
// currently hovering between items
|
|
26
|
-
if (
|
|
27
|
-
// TODO move check in reusable function
|
|
27
|
+
if (isOrderedDragTarget(dragTarget)) {
|
|
28
28
|
const parent = dragTarget.item.getParent();
|
|
29
29
|
const targetedItem = tree.getItems()[dragTarget.dragLineIndex - 1]; // item above dragline
|
|
30
30
|
|
|
@@ -107,7 +107,7 @@ const getNextValidDragTarget = <T>(
|
|
|
107
107
|
|
|
108
108
|
const updateScroll = <T>(tree: TreeInstance<T>) => {
|
|
109
109
|
const state = tree.getState().dnd;
|
|
110
|
-
if (!state?.dragTarget ||
|
|
110
|
+
if (!state?.dragTarget || isOrderedDragTarget(state.dragTarget)) return;
|
|
111
111
|
state.dragTarget.item.scrollTo({ block: "nearest", inline: "nearest" });
|
|
112
112
|
};
|
|
113
113
|
|
|
@@ -152,7 +152,7 @@ const moveDragPosition = <T>(tree: TreeInstance<T>, isUp: boolean) => {
|
|
|
152
152
|
dragTarget,
|
|
153
153
|
});
|
|
154
154
|
tree.applySubStateUpdate("assistiveDndState", AssistiveDndState.Dragging);
|
|
155
|
-
if (!(
|
|
155
|
+
if (!isOrderedDragTarget(dragTarget)) {
|
|
156
156
|
dragTarget.item.setFocused();
|
|
157
157
|
}
|
|
158
158
|
updateScroll(tree);
|
|
@@ -6,6 +6,7 @@ import { propMemoizationFeature } from "../prop-memoization/feature";
|
|
|
6
6
|
import { keyboardDragAndDropFeature } from "./feature";
|
|
7
7
|
import { dragAndDropFeature } from "../drag-and-drop/feature";
|
|
8
8
|
import { AssistiveDndState } from "./types";
|
|
9
|
+
import { isOrderedDragTarget } from "../drag-and-drop/utils";
|
|
9
10
|
|
|
10
11
|
const isItem = (item: unknown): item is ItemInstance<any> =>
|
|
11
12
|
!!item && typeof item === "object" && "getId" in item;
|
|
@@ -318,7 +319,7 @@ describe("core-feature/keyboard-drag-and-drop", () => {
|
|
|
318
319
|
describe("drag restrictions", () => {
|
|
319
320
|
const expectChildIndex = (index: number) => {
|
|
320
321
|
const state = tree.instance.getState().dnd?.dragTarget;
|
|
321
|
-
if (!state || !(
|
|
322
|
+
if (!state || !isOrderedDragTarget(state))
|
|
322
323
|
throw new Error("No childIndex");
|
|
323
324
|
expect(state.childIndex).toEqual(index);
|
|
324
325
|
};
|
|
@@ -3,15 +3,14 @@ import { PropMemoizationDataRef } from "./types";
|
|
|
3
3
|
|
|
4
4
|
const memoize = (
|
|
5
5
|
props: Record<string, any>,
|
|
6
|
-
|
|
6
|
+
memoizedProps: Record<string, any>,
|
|
7
7
|
) => {
|
|
8
|
-
dataRef.memoizedProps ??= {};
|
|
9
8
|
for (const key in props) {
|
|
10
9
|
if (typeof props[key] === "function") {
|
|
11
|
-
if (key in
|
|
12
|
-
props[key] =
|
|
10
|
+
if (memoizedProps && key in memoizedProps) {
|
|
11
|
+
props[key] = memoizedProps[key];
|
|
13
12
|
} else {
|
|
14
|
-
|
|
13
|
+
memoizedProps[key] = props[key];
|
|
15
14
|
}
|
|
16
15
|
}
|
|
17
16
|
}
|
|
@@ -37,7 +36,17 @@ export const propMemoizationFeature: FeatureImplementation = {
|
|
|
37
36
|
getContainerProps: ({ tree, prev }, treeLabel) => {
|
|
38
37
|
const dataRef = tree.getDataRef<PropMemoizationDataRef>();
|
|
39
38
|
const props = prev?.(treeLabel) ?? {};
|
|
40
|
-
|
|
39
|
+
dataRef.current.memo ??= {};
|
|
40
|
+
dataRef.current.memo.tree ??= {};
|
|
41
|
+
return memoize(props, dataRef.current.memo.tree);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
getSearchInputElementProps: ({ tree, prev }) => {
|
|
45
|
+
const dataRef = tree.getDataRef<PropMemoizationDataRef>();
|
|
46
|
+
const props = prev?.() ?? {};
|
|
47
|
+
dataRef.current.memo ??= {};
|
|
48
|
+
dataRef.current.memo.search ??= {};
|
|
49
|
+
return memoize(props, dataRef.current.memo.search);
|
|
41
50
|
},
|
|
42
51
|
},
|
|
43
52
|
|
|
@@ -45,7 +54,17 @@ export const propMemoizationFeature: FeatureImplementation = {
|
|
|
45
54
|
getProps: ({ item, prev }) => {
|
|
46
55
|
const dataRef = item.getDataRef<PropMemoizationDataRef>();
|
|
47
56
|
const props = prev?.() ?? {};
|
|
48
|
-
|
|
57
|
+
dataRef.current.memo ??= {};
|
|
58
|
+
dataRef.current.memo.item ??= {};
|
|
59
|
+
return memoize(props, dataRef.current.memo.item);
|
|
60
|
+
},
|
|
61
|
+
|
|
62
|
+
getRenameInputProps: ({ item, prev }) => {
|
|
63
|
+
const dataRef = item.getDataRef<PropMemoizationDataRef>();
|
|
64
|
+
const props = prev?.() ?? {};
|
|
65
|
+
dataRef.current.memo ??= {};
|
|
66
|
+
dataRef.current.memo.rename ??= {};
|
|
67
|
+
return memoize(props, dataRef.current.memo.rename);
|
|
49
68
|
},
|
|
50
69
|
},
|
|
51
70
|
};
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export interface PropMemoizationDataRef {
|
|
2
|
-
|
|
2
|
+
memo?: {
|
|
3
|
+
tree?: Record<string, any>;
|
|
4
|
+
item?: Record<string, any>;
|
|
5
|
+
search?: Record<string, any>;
|
|
6
|
+
rename?: Record<string, any>;
|
|
7
|
+
};
|
|
3
8
|
}
|
|
4
9
|
|
|
5
10
|
export type PropMemoizationFeatureDef = {
|
|
@@ -22,7 +22,7 @@ export type SearchFeatureDef<T> = {
|
|
|
22
22
|
closeSearch: () => void;
|
|
23
23
|
isSearchOpen: () => boolean;
|
|
24
24
|
getSearchValue: () => string;
|
|
25
|
-
registerSearchInputElement: (element: HTMLInputElement | null) => void;
|
|
25
|
+
registerSearchInputElement: (element: HTMLInputElement | null) => void; // TODO remove
|
|
26
26
|
getSearchInputElement: () => HTMLInputElement | null;
|
|
27
27
|
getSearchInputElementProps: () => any;
|
|
28
28
|
getSearchMatchingItems: () => ItemInstance<T>[];
|
|
@@ -43,9 +43,9 @@ export const selectionFeature: FeatureImplementation = {
|
|
|
43
43
|
tree.setSelectedItems(selectedItems.filter((id) => id !== itemId));
|
|
44
44
|
},
|
|
45
45
|
|
|
46
|
-
isSelected: ({ tree,
|
|
46
|
+
isSelected: ({ tree, itemId }) => {
|
|
47
47
|
const { selectedItems } = tree.getState();
|
|
48
|
-
return selectedItems.includes(
|
|
48
|
+
return selectedItems.includes(itemId);
|
|
49
49
|
},
|
|
50
50
|
|
|
51
51
|
selectUpTo: ({ tree, item }, ctrl: boolean) => {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FeatureImplementation, ItemInstance } from "../../types/core";
|
|
2
2
|
import { ItemMeta } from "./types";
|
|
3
3
|
import { makeStateUpdater, poll } from "../../utils";
|
|
4
|
+
import { logWarning } from "../../utilities/errors";
|
|
4
5
|
|
|
5
6
|
export const treeFeature: FeatureImplementation<any> = {
|
|
6
7
|
key: "tree",
|
|
@@ -31,16 +32,21 @@ export const treeFeature: FeatureImplementation<any> = {
|
|
|
31
32
|
|
|
32
33
|
const recursiveAdd = (
|
|
33
34
|
itemId: string,
|
|
34
|
-
|
|
35
|
+
path: string[],
|
|
35
36
|
level: number,
|
|
36
37
|
setSize: number,
|
|
37
38
|
posInSet: number,
|
|
38
39
|
) => {
|
|
40
|
+
if (path.includes(itemId)) {
|
|
41
|
+
logWarning(`Circular reference for ${path.join(".")}`);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
39
45
|
flatItems.push({
|
|
40
46
|
itemId,
|
|
41
47
|
level,
|
|
42
48
|
index: flatItems.length,
|
|
43
|
-
parentId,
|
|
49
|
+
parentId: path.at(-1) as string,
|
|
44
50
|
setSize,
|
|
45
51
|
posInSet,
|
|
46
52
|
});
|
|
@@ -49,7 +55,13 @@ export const treeFeature: FeatureImplementation<any> = {
|
|
|
49
55
|
const children = tree.retrieveChildrenIds(itemId) ?? [];
|
|
50
56
|
let i = 0;
|
|
51
57
|
for (const childId of children) {
|
|
52
|
-
recursiveAdd(
|
|
58
|
+
recursiveAdd(
|
|
59
|
+
childId,
|
|
60
|
+
path.concat(itemId),
|
|
61
|
+
level + 1,
|
|
62
|
+
children.length,
|
|
63
|
+
i++,
|
|
64
|
+
);
|
|
53
65
|
}
|
|
54
66
|
}
|
|
55
67
|
};
|
|
@@ -57,13 +69,12 @@ export const treeFeature: FeatureImplementation<any> = {
|
|
|
57
69
|
const children = tree.retrieveChildrenIds(rootItemId);
|
|
58
70
|
let i = 0;
|
|
59
71
|
for (const itemId of children) {
|
|
60
|
-
recursiveAdd(itemId, rootItemId, 0, children.length, i++);
|
|
72
|
+
recursiveAdd(itemId, [rootItemId], 0, children.length, i++);
|
|
61
73
|
}
|
|
62
74
|
|
|
63
75
|
return flatItems;
|
|
64
76
|
},
|
|
65
77
|
|
|
66
|
-
// TODO memo
|
|
67
78
|
getFocusedItem: ({ tree }) => {
|
|
68
79
|
return (
|
|
69
80
|
tree.getItemInstance(tree.getState().focusedItem ?? "") ??
|
|
@@ -72,14 +83,16 @@ export const treeFeature: FeatureImplementation<any> = {
|
|
|
72
83
|
},
|
|
73
84
|
|
|
74
85
|
focusNextItem: ({ tree }) => {
|
|
75
|
-
const
|
|
76
|
-
|
|
86
|
+
const focused = tree.getFocusedItem().getItemMeta();
|
|
87
|
+
if (!focused) return;
|
|
88
|
+
const nextIndex = Math.min(focused.index + 1, tree.getItems().length - 1);
|
|
77
89
|
tree.getItems()[nextIndex]?.setFocused();
|
|
78
90
|
},
|
|
79
91
|
|
|
80
92
|
focusPreviousItem: ({ tree }) => {
|
|
81
|
-
const
|
|
82
|
-
|
|
93
|
+
const focused = tree.getFocusedItem().getItemMeta();
|
|
94
|
+
if (!focused) return;
|
|
95
|
+
const nextIndex = Math.max(focused.index - 1, 0);
|
|
83
96
|
tree.getItems()[nextIndex]?.setFocused();
|
|
84
97
|
},
|
|
85
98
|
|
|
@@ -95,7 +108,6 @@ export const treeFeature: FeatureImplementation<any> = {
|
|
|
95
108
|
});
|
|
96
109
|
},
|
|
97
110
|
|
|
98
|
-
// TODO add label parameter
|
|
99
111
|
getContainerProps: ({ prev, tree }, treeLabel) => ({
|
|
100
112
|
...prev?.(),
|
|
101
113
|
role: "tree",
|
package/src/utilities/errors.ts
CHANGED
|
@@ -1,2 +1,6 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
const prefix = "Headless Tree: ";
|
|
2
|
+
|
|
3
|
+
export const throwError = (message: string) => Error(prefix + message);
|
|
4
|
+
|
|
5
|
+
// eslint-disable-next-line no-console
|
|
6
|
+
export const logWarning = (message: string) => console.warn(prefix + message);
|