@headless-tree/core 1.0.1 → 1.1.0
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 +15 -0
- package/lib/cjs/features/async-data-loader/feature.js +78 -68
- package/lib/cjs/features/async-data-loader/types.d.ts +12 -7
- package/lib/cjs/features/drag-and-drop/feature.js +1 -0
- package/lib/cjs/features/expand-all/feature.js +2 -2
- package/lib/cjs/features/hotkeys-core/feature.js +50 -18
- package/lib/cjs/features/hotkeys-core/types.d.ts +3 -0
- package/lib/cjs/features/keyboard-drag-and-drop/feature.js +1 -1
- package/lib/cjs/features/selection/feature.js +1 -1
- package/lib/cjs/features/sync-data-loader/feature.js +13 -9
- package/lib/cjs/features/sync-data-loader/types.d.ts +11 -2
- package/lib/cjs/test-utils/test-tree-expect.js +1 -0
- package/lib/esm/features/async-data-loader/feature.js +78 -68
- package/lib/esm/features/async-data-loader/types.d.ts +12 -7
- package/lib/esm/features/drag-and-drop/feature.js +1 -0
- package/lib/esm/features/expand-all/feature.js +2 -2
- package/lib/esm/features/hotkeys-core/feature.js +50 -18
- package/lib/esm/features/hotkeys-core/types.d.ts +3 -0
- package/lib/esm/features/keyboard-drag-and-drop/feature.js +1 -1
- package/lib/esm/features/selection/feature.js +1 -1
- package/lib/esm/features/sync-data-loader/feature.js +13 -9
- package/lib/esm/features/sync-data-loader/types.d.ts +11 -2
- package/lib/esm/test-utils/test-tree-expect.js +1 -0
- package/package.json +1 -1
- package/src/features/async-data-loader/async-data-loader.spec.ts +82 -0
- package/src/features/async-data-loader/feature.ts +92 -67
- package/src/features/async-data-loader/types.ts +14 -7
- package/src/features/drag-and-drop/feature.ts +1 -0
- package/src/features/expand-all/feature.ts +2 -2
- package/src/features/hotkeys-core/feature.ts +56 -17
- package/src/features/hotkeys-core/types.ts +4 -0
- package/src/features/keyboard-drag-and-drop/feature.ts +1 -1
- package/src/features/selection/feature.ts +1 -1
- package/src/features/sync-data-loader/feature.ts +16 -9
- package/src/features/sync-data-loader/types.ts +11 -4
- package/src/test-utils/test-tree-expect.ts +1 -0
- package/readme.md +0 -157
|
@@ -1,7 +1,61 @@
|
|
|
1
|
-
import { FeatureImplementation } from "../../types/core";
|
|
1
|
+
import { FeatureImplementation, TreeInstance } from "../../types/core";
|
|
2
2
|
import { AsyncDataLoaderDataRef } from "./types";
|
|
3
3
|
import { makeStateUpdater } from "../../utils";
|
|
4
4
|
|
|
5
|
+
const getDataRef = <T>(tree: TreeInstance<T>) => {
|
|
6
|
+
const dataRef = tree.getDataRef<AsyncDataLoaderDataRef>();
|
|
7
|
+
dataRef.current.itemData ??= {};
|
|
8
|
+
dataRef.current.childrenIds ??= {};
|
|
9
|
+
return dataRef;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const loadItemData = async <T>(tree: TreeInstance<T>, itemId: string) => {
|
|
13
|
+
const config = tree.getConfig();
|
|
14
|
+
const dataRef = getDataRef(tree);
|
|
15
|
+
|
|
16
|
+
const item = await config.dataLoader.getItem(itemId);
|
|
17
|
+
dataRef.current.itemData[itemId] = item;
|
|
18
|
+
config.onLoadedItem?.(itemId, item);
|
|
19
|
+
tree.applySubStateUpdate("loadingItemData", (loadingItemData) =>
|
|
20
|
+
loadingItemData.filter((id) => id !== itemId),
|
|
21
|
+
);
|
|
22
|
+
|
|
23
|
+
return item;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const loadChildrenIds = async <T>(tree: TreeInstance<T>, itemId: string) => {
|
|
27
|
+
const config = tree.getConfig();
|
|
28
|
+
const dataRef = getDataRef(tree);
|
|
29
|
+
let childrenIds: string[];
|
|
30
|
+
|
|
31
|
+
if ("getChildrenWithData" in config.dataLoader) {
|
|
32
|
+
const children = await config.dataLoader.getChildrenWithData(itemId);
|
|
33
|
+
childrenIds = children.map((c) => c.id);
|
|
34
|
+
dataRef.current.childrenIds[itemId] = childrenIds;
|
|
35
|
+
children.forEach(({ id, data }) => {
|
|
36
|
+
dataRef.current.itemData[id] = data;
|
|
37
|
+
config.onLoadedItem?.(id, data);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
config.onLoadedChildren?.(itemId, childrenIds);
|
|
41
|
+
tree.rebuildTree();
|
|
42
|
+
tree.applySubStateUpdate("loadingItemData", (loadingItemData) =>
|
|
43
|
+
loadingItemData.filter((id) => !childrenIds.includes(id)),
|
|
44
|
+
);
|
|
45
|
+
} else {
|
|
46
|
+
childrenIds = await config.dataLoader.getChildren(itemId);
|
|
47
|
+
dataRef.current.childrenIds[itemId] = childrenIds;
|
|
48
|
+
config.onLoadedChildren?.(itemId, childrenIds);
|
|
49
|
+
tree.rebuildTree();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
tree.applySubStateUpdate("loadingItemChildrens", (loadingItemChildrens) =>
|
|
53
|
+
loadingItemChildrens.filter((id) => id !== itemId),
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
return childrenIds;
|
|
57
|
+
};
|
|
58
|
+
|
|
5
59
|
export const asyncDataLoaderFeature: FeatureImplementation = {
|
|
6
60
|
key: "async-data-loader",
|
|
7
61
|
|
|
@@ -23,37 +77,27 @@ export const asyncDataLoaderFeature: FeatureImplementation = {
|
|
|
23
77
|
},
|
|
24
78
|
|
|
25
79
|
treeInstance: {
|
|
26
|
-
waitForItemDataLoaded:
|
|
27
|
-
tree.retrieveItemData(itemId);
|
|
28
|
-
if (!tree.getState().loadingItemData.includes(itemId)) {
|
|
29
|
-
return;
|
|
30
|
-
}
|
|
31
|
-
await new Promise<void>((resolve) => {
|
|
32
|
-
const dataRef = tree.getDataRef<AsyncDataLoaderDataRef>();
|
|
33
|
-
dataRef.current.awaitingItemDataLoading ??= {};
|
|
34
|
-
dataRef.current.awaitingItemDataLoading[itemId] ??= [];
|
|
35
|
-
dataRef.current.awaitingItemDataLoading[itemId].push(resolve);
|
|
36
|
-
});
|
|
37
|
-
},
|
|
80
|
+
waitForItemDataLoaded: ({ tree }, itemId) => tree.loadItemData(itemId),
|
|
38
81
|
|
|
39
|
-
waitForItemChildrenLoaded:
|
|
40
|
-
tree.
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
82
|
+
waitForItemChildrenLoaded: ({ tree }, itemId) =>
|
|
83
|
+
tree.loadChildrenIds(itemId),
|
|
84
|
+
|
|
85
|
+
loadItemData: async ({ tree }, itemId) => {
|
|
86
|
+
return (
|
|
87
|
+
getDataRef(tree).current.itemData[itemId] ??
|
|
88
|
+
(await loadItemData(tree, itemId))
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
loadChildrenIds: async ({ tree }, itemId) => {
|
|
92
|
+
return (
|
|
93
|
+
getDataRef(tree).current.childrenIds[itemId] ??
|
|
94
|
+
(await loadChildrenIds(tree, itemId))
|
|
95
|
+
);
|
|
50
96
|
},
|
|
51
97
|
|
|
52
98
|
retrieveItemData: ({ tree }, itemId) => {
|
|
53
99
|
const config = tree.getConfig();
|
|
54
|
-
const dataRef =
|
|
55
|
-
dataRef.current.itemData ??= {};
|
|
56
|
-
dataRef.current.childrenIds ??= {};
|
|
100
|
+
const dataRef = getDataRef(tree);
|
|
57
101
|
|
|
58
102
|
if (dataRef.current.itemData[itemId]) {
|
|
59
103
|
return dataRef.current.itemData[itemId];
|
|
@@ -65,28 +109,14 @@ export const asyncDataLoaderFeature: FeatureImplementation = {
|
|
|
65
109
|
itemId,
|
|
66
110
|
]);
|
|
67
111
|
|
|
68
|
-
(
|
|
69
|
-
const item = await config.dataLoader.getItem(itemId);
|
|
70
|
-
dataRef.current.itemData[itemId] = item;
|
|
71
|
-
config.onLoadedItem?.(itemId, item);
|
|
72
|
-
tree.applySubStateUpdate("loadingItemData", (loadingItemData) =>
|
|
73
|
-
loadingItemData.filter((id) => id !== itemId),
|
|
74
|
-
);
|
|
75
|
-
|
|
76
|
-
dataRef.current.awaitingItemDataLoading?.[itemId].forEach((cb) =>
|
|
77
|
-
cb(),
|
|
78
|
-
);
|
|
79
|
-
delete dataRef.current.awaitingItemDataLoading?.[itemId];
|
|
80
|
-
})();
|
|
112
|
+
loadItemData(tree, itemId);
|
|
81
113
|
}
|
|
82
114
|
|
|
83
115
|
return config.createLoadingItemData?.() ?? null;
|
|
84
116
|
},
|
|
85
117
|
|
|
86
118
|
retrieveChildrenIds: ({ tree }, itemId) => {
|
|
87
|
-
const
|
|
88
|
-
const dataRef = tree.getDataRef<AsyncDataLoaderDataRef>();
|
|
89
|
-
dataRef.current.childrenIds ??= {};
|
|
119
|
+
const dataRef = getDataRef(tree);
|
|
90
120
|
if (dataRef.current.childrenIds[itemId]) {
|
|
91
121
|
return dataRef.current.childrenIds[itemId];
|
|
92
122
|
}
|
|
@@ -100,22 +130,7 @@ export const asyncDataLoaderFeature: FeatureImplementation = {
|
|
|
100
130
|
(loadingItemChildrens) => [...loadingItemChildrens, itemId],
|
|
101
131
|
);
|
|
102
132
|
|
|
103
|
-
(
|
|
104
|
-
const childrenIds = await config.dataLoader.getChildren(itemId);
|
|
105
|
-
dataRef.current.childrenIds[itemId] = childrenIds;
|
|
106
|
-
config.onLoadedChildren?.(itemId, childrenIds);
|
|
107
|
-
tree.applySubStateUpdate(
|
|
108
|
-
"loadingItemChildrens",
|
|
109
|
-
(loadingItemChildrens) =>
|
|
110
|
-
loadingItemChildrens.filter((id) => id !== itemId),
|
|
111
|
-
);
|
|
112
|
-
tree.rebuildTree();
|
|
113
|
-
|
|
114
|
-
dataRef.current.awaitingItemChildrensLoading?.[itemId]?.forEach((cb) =>
|
|
115
|
-
cb(),
|
|
116
|
-
);
|
|
117
|
-
delete dataRef.current.awaitingItemChildrensLoading?.[itemId];
|
|
118
|
-
})();
|
|
133
|
+
loadChildrenIds(tree, itemId);
|
|
119
134
|
|
|
120
135
|
return [];
|
|
121
136
|
},
|
|
@@ -125,15 +140,25 @@ export const asyncDataLoaderFeature: FeatureImplementation = {
|
|
|
125
140
|
isLoading: ({ tree, item }) =>
|
|
126
141
|
tree.getState().loadingItemData.includes(item.getItemMeta().itemId) ||
|
|
127
142
|
tree.getState().loadingItemChildrens.includes(item.getItemMeta().itemId),
|
|
128
|
-
invalidateItemData: ({ tree, itemId }) => {
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
143
|
+
invalidateItemData: async ({ tree, itemId }, optimistic) => {
|
|
144
|
+
if (!optimistic) {
|
|
145
|
+
delete getDataRef(tree).current.itemData?.[itemId];
|
|
146
|
+
tree.applySubStateUpdate("loadingItemData", (loadingItemData) => [
|
|
147
|
+
...loadingItemData,
|
|
148
|
+
itemId,
|
|
149
|
+
]);
|
|
150
|
+
}
|
|
151
|
+
await loadItemData(tree, itemId);
|
|
132
152
|
},
|
|
133
|
-
invalidateChildrenIds: ({ tree, itemId }) => {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
153
|
+
invalidateChildrenIds: async ({ tree, itemId }, optimistic) => {
|
|
154
|
+
if (!optimistic) {
|
|
155
|
+
delete getDataRef(tree).current.childrenIds?.[itemId];
|
|
156
|
+
tree.applySubStateUpdate(
|
|
157
|
+
"loadingItemChildrens",
|
|
158
|
+
(loadingItemChildrens) => [...loadingItemChildrens, itemId],
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
await loadChildrenIds(tree, itemId);
|
|
137
162
|
},
|
|
138
163
|
updateCachedChildrenIds: ({ tree, itemId }, childrenIds) => {
|
|
139
164
|
const dataRef = tree.getDataRef<AsyncDataLoaderDataRef>();
|
|
@@ -1,13 +1,9 @@
|
|
|
1
1
|
import { SetStateFn } from "../../types/core";
|
|
2
2
|
import { SyncDataLoaderFeatureDef } from "../sync-data-loader/types";
|
|
3
3
|
|
|
4
|
-
type AwaitingLoaderCallbacks = Record<string, (() => void)[]>;
|
|
5
|
-
|
|
6
4
|
export interface AsyncDataLoaderDataRef<T = any> {
|
|
7
5
|
itemData: Record<string, T>;
|
|
8
6
|
childrenIds: Record<string, string[]>;
|
|
9
|
-
awaitingItemDataLoading: AwaitingLoaderCallbacks;
|
|
10
|
-
awaitingItemChildrensLoading: AwaitingLoaderCallbacks;
|
|
11
7
|
}
|
|
12
8
|
|
|
13
9
|
/**
|
|
@@ -32,13 +28,24 @@ export type AsyncDataLoaderFeatureDef<T> = {
|
|
|
32
28
|
onLoadedChildren?: (itemId: string, childrenIds: string[]) => void;
|
|
33
29
|
};
|
|
34
30
|
treeInstance: SyncDataLoaderFeatureDef<T>["treeInstance"] & {
|
|
31
|
+
/** @deprecated use loadItemData instead */
|
|
35
32
|
waitForItemDataLoaded: (itemId: string) => Promise<void>;
|
|
33
|
+
/** @deprecated use loadChildrenIds instead */
|
|
36
34
|
waitForItemChildrenLoaded: (itemId: string) => Promise<void>;
|
|
35
|
+
loadItemData: (itemId: string) => Promise<T>;
|
|
36
|
+
loadChildrenIds: (itemId: string) => Promise<string[]>;
|
|
37
37
|
};
|
|
38
38
|
itemInstance: SyncDataLoaderFeatureDef<T>["itemInstance"] & {
|
|
39
|
-
/** Invalidate fetched data for item, and triggers a refetch and subsequent rerender if the item is visible
|
|
40
|
-
|
|
41
|
-
|
|
39
|
+
/** Invalidate fetched data for item, and triggers a refetch and subsequent rerender if the item is visible
|
|
40
|
+
* @param optimistic If true, the item will not trigger a state update on `loadingItemData`, and
|
|
41
|
+
* the tree will continue to display the old data until the new data has loaded. */
|
|
42
|
+
invalidateItemData: (optimistic?: boolean) => Promise<void>;
|
|
43
|
+
|
|
44
|
+
/** Invalidate fetched children ids for item, and triggers a refetch and subsequent rerender if the item is visible
|
|
45
|
+
* @param optimistic If true, the item will not trigger a state update on `loadingItemChildrens`, and
|
|
46
|
+
* the tree will continue to display the old data until the new data has loaded. */
|
|
47
|
+
invalidateChildrenIds: (optimistic?: boolean) => Promise<void>;
|
|
48
|
+
|
|
42
49
|
updateCachedChildrenIds: (childrenIds: string[]) => void;
|
|
43
50
|
isLoading: () => boolean;
|
|
44
51
|
};
|
|
@@ -74,6 +74,7 @@ export const dragAndDropFeature: FeatureImplementation = {
|
|
|
74
74
|
const dragLine = tree.getDragLineData();
|
|
75
75
|
return dragLine
|
|
76
76
|
? {
|
|
77
|
+
position: "absolute",
|
|
77
78
|
top: `${dragLine.top + topOffset}px`,
|
|
78
79
|
left: `${dragLine.left + leftOffset}px`,
|
|
79
80
|
width: `${dragLine.width - leftOffset}px`,
|
|
@@ -50,7 +50,7 @@ export const expandAllFeature: FeatureImplementation = {
|
|
|
50
50
|
handler: async (_, tree) => {
|
|
51
51
|
const cancelToken = { current: false };
|
|
52
52
|
const cancelHandler = (e: KeyboardEvent) => {
|
|
53
|
-
if (e.
|
|
53
|
+
if (e.code === "Escape") {
|
|
54
54
|
cancelToken.current = true;
|
|
55
55
|
}
|
|
56
56
|
};
|
|
@@ -63,7 +63,7 @@ export const expandAllFeature: FeatureImplementation = {
|
|
|
63
63
|
},
|
|
64
64
|
|
|
65
65
|
collapseSelected: {
|
|
66
|
-
hotkey: "Control+Shift
|
|
66
|
+
hotkey: "Control+Shift+Minus",
|
|
67
67
|
handler: (_, tree) => {
|
|
68
68
|
tree.getSelectedItems().forEach((item) => item.collapseAll());
|
|
69
69
|
},
|
|
@@ -6,10 +6,13 @@ import {
|
|
|
6
6
|
import { HotkeyConfig, HotkeysCoreDataRef } from "./types";
|
|
7
7
|
|
|
8
8
|
const specialKeys: Record<string, RegExp> = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
// TODO:breaking deprecate auto-lowercase
|
|
10
|
+
letter: /^Key[A-Z]$/,
|
|
11
|
+
letterornumber: /^(Key[A-Z]|Digit[0-9])$/,
|
|
12
|
+
plus: /^(NumpadAdd|Plus)$/,
|
|
13
|
+
minus: /^(NumpadSubtract|Minus)$/,
|
|
14
|
+
control: /^(ControlLeft|ControlRight)$/,
|
|
15
|
+
shift: /^(ShiftLeft|ShiftRight)$/,
|
|
13
16
|
};
|
|
14
17
|
|
|
15
18
|
const testHotkeyMatch = (
|
|
@@ -17,12 +20,28 @@ const testHotkeyMatch = (
|
|
|
17
20
|
tree: TreeInstance<any>,
|
|
18
21
|
hotkey: HotkeyConfig<any>,
|
|
19
22
|
) => {
|
|
20
|
-
const supposedKeys = hotkey.hotkey.toLowerCase().split("+");
|
|
21
|
-
const doKeysMatch = supposedKeys.every((key) =>
|
|
22
|
-
key in specialKeys
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
const supposedKeys = hotkey.hotkey.toLowerCase().split("+"); // TODO:breaking deprecate auto-lowercase
|
|
24
|
+
const doKeysMatch = supposedKeys.every((key) => {
|
|
25
|
+
if (key in specialKeys) {
|
|
26
|
+
return [...pressedKeys].some((pressedKey) =>
|
|
27
|
+
specialKeys[key].test(pressedKey),
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const pressedKeysLowerCase = [...pressedKeys] // TODO:breaking deprecate auto-lowercase
|
|
32
|
+
.map((k) => k.toLowerCase());
|
|
33
|
+
|
|
34
|
+
if (pressedKeysLowerCase.includes(key.toLowerCase())) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (pressedKeysLowerCase.includes(`key${key.toLowerCase()}`)) {
|
|
39
|
+
// TODO:breaking deprecate e.key character matching
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return false;
|
|
44
|
+
});
|
|
26
45
|
const isEnabled = !hotkey.isEnabled || hotkey.isEnabled(tree);
|
|
27
46
|
const equalCounts = pressedKeys.size === supposedKeys.length;
|
|
28
47
|
return doKeysMatch && isEnabled && equalCounts;
|
|
@@ -45,23 +64,33 @@ export const hotkeysCoreFeature: FeatureImplementation = {
|
|
|
45
64
|
onTreeMount: (tree, element) => {
|
|
46
65
|
const data = tree.getDataRef<HotkeysCoreDataRef>();
|
|
47
66
|
const keydown = (e: KeyboardEvent) => {
|
|
48
|
-
const
|
|
67
|
+
const { ignoreHotkeysOnInputs, onTreeHotkey, hotkeys } = tree.getConfig();
|
|
68
|
+
if (e.target instanceof HTMLInputElement && ignoreHotkeysOnInputs) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
49
72
|
data.current.pressedKeys ??= new Set();
|
|
50
|
-
const newMatch = !data.current.pressedKeys.has(
|
|
51
|
-
data.current.pressedKeys.add(
|
|
73
|
+
const newMatch = !data.current.pressedKeys.has(e.code);
|
|
74
|
+
data.current.pressedKeys.add(e.code);
|
|
52
75
|
|
|
53
76
|
const hotkeyName = findHotkeyMatch(
|
|
54
77
|
data.current.pressedKeys,
|
|
55
78
|
tree as any,
|
|
56
79
|
tree.getHotkeyPresets(),
|
|
57
|
-
|
|
80
|
+
hotkeys as HotkeysConfig<any>,
|
|
58
81
|
);
|
|
59
82
|
|
|
83
|
+
if (e.target instanceof HTMLInputElement) {
|
|
84
|
+
// JS respects composite keydowns while input elements are focused, and
|
|
85
|
+
// doesnt send the associated keyup events with the same key name
|
|
86
|
+
data.current.pressedKeys.delete(e.code);
|
|
87
|
+
}
|
|
88
|
+
|
|
60
89
|
if (!hotkeyName) return;
|
|
61
90
|
|
|
62
91
|
const hotkeyConfig: HotkeyConfig<any> = {
|
|
63
92
|
...tree.getHotkeyPresets()[hotkeyName],
|
|
64
|
-
...
|
|
93
|
+
...hotkeys?.[hotkeyName],
|
|
65
94
|
};
|
|
66
95
|
|
|
67
96
|
if (!hotkeyConfig) return;
|
|
@@ -74,12 +103,16 @@ export const hotkeysCoreFeature: FeatureImplementation = {
|
|
|
74
103
|
if (hotkeyConfig.preventDefault) e.preventDefault();
|
|
75
104
|
|
|
76
105
|
hotkeyConfig.handler(e, tree as any);
|
|
77
|
-
|
|
106
|
+
onTreeHotkey?.(hotkeyName, e);
|
|
78
107
|
};
|
|
79
108
|
|
|
80
109
|
const keyup = (e: KeyboardEvent) => {
|
|
81
110
|
data.current.pressedKeys ??= new Set();
|
|
82
|
-
data.current.pressedKeys.delete(e.
|
|
111
|
+
data.current.pressedKeys.delete(e.code);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const reset = () => {
|
|
115
|
+
data.current.pressedKeys = new Set();
|
|
83
116
|
};
|
|
84
117
|
|
|
85
118
|
// keyup is registered on document, because some hotkeys shift
|
|
@@ -87,8 +120,10 @@ export const hotkeysCoreFeature: FeatureImplementation = {
|
|
|
87
120
|
// and then we wouldn't get the keyup event anymore
|
|
88
121
|
element.addEventListener("keydown", keydown);
|
|
89
122
|
document.addEventListener("keyup", keyup);
|
|
123
|
+
window.addEventListener("focus", reset);
|
|
90
124
|
data.current.keydownHandler = keydown;
|
|
91
125
|
data.current.keyupHandler = keyup;
|
|
126
|
+
data.current.resetHandler = reset;
|
|
92
127
|
},
|
|
93
128
|
|
|
94
129
|
onTreeUnmount: (tree, element) => {
|
|
@@ -101,5 +136,9 @@ export const hotkeysCoreFeature: FeatureImplementation = {
|
|
|
101
136
|
element.removeEventListener("keydown", data.current.keydownHandler);
|
|
102
137
|
delete data.current.keydownHandler;
|
|
103
138
|
}
|
|
139
|
+
if (data.current.resetHandler) {
|
|
140
|
+
window.removeEventListener("focus", data.current.resetHandler);
|
|
141
|
+
delete data.current.resetHandler;
|
|
142
|
+
}
|
|
104
143
|
},
|
|
105
144
|
};
|
|
@@ -12,6 +12,7 @@ export interface HotkeyConfig<T> {
|
|
|
12
12
|
export interface HotkeysCoreDataRef {
|
|
13
13
|
keydownHandler?: (e: KeyboardEvent) => void;
|
|
14
14
|
keyupHandler?: (e: KeyboardEvent) => void;
|
|
15
|
+
resetHandler?: (e: FocusEvent) => void;
|
|
15
16
|
pressedKeys: Set<string>;
|
|
16
17
|
}
|
|
17
18
|
|
|
@@ -20,6 +21,9 @@ export type HotkeysCoreFeatureDef<T> = {
|
|
|
20
21
|
config: {
|
|
21
22
|
hotkeys?: CustomHotkeysConfig<T>;
|
|
22
23
|
onTreeHotkey?: (name: string, e: KeyboardEvent) => void;
|
|
24
|
+
|
|
25
|
+
/** Do not handle key inputs while an HTML input element is focused */
|
|
26
|
+
ignoreHotkeysOnInputs?: boolean;
|
|
23
27
|
};
|
|
24
28
|
treeInstance: {};
|
|
25
29
|
itemInstance: {};
|
|
@@ -187,7 +187,7 @@ export const keyboardDragAndDropFeature: FeatureImplementation = {
|
|
|
187
187
|
|
|
188
188
|
hotkeys: {
|
|
189
189
|
startDrag: {
|
|
190
|
-
hotkey: "Control+Shift+
|
|
190
|
+
hotkey: "Control+Shift+KeyD",
|
|
191
191
|
preventDefault: true,
|
|
192
192
|
isEnabled: (tree) => !tree.getState().dnd,
|
|
193
193
|
handler: (_, tree) => {
|
|
@@ -144,7 +144,7 @@ export const selectionFeature: FeatureImplementation = {
|
|
|
144
144
|
},
|
|
145
145
|
},
|
|
146
146
|
selectAll: {
|
|
147
|
-
hotkey: "Control+
|
|
147
|
+
hotkey: "Control+KeyA",
|
|
148
148
|
preventDefault: true,
|
|
149
149
|
handler: (e, tree) => {
|
|
150
150
|
tree.setSelectedItems(tree.getItems().map((item) => item.getId()));
|
|
@@ -3,6 +3,12 @@ import { makeStateUpdater } from "../../utils";
|
|
|
3
3
|
import { throwError } from "../../utilities/errors";
|
|
4
4
|
|
|
5
5
|
const promiseErrorMessage = "sync dataLoader returned promise";
|
|
6
|
+
const unpromise = <T>(data: T | Promise<T>): T => {
|
|
7
|
+
if (!data || (typeof data === "object" && "then" in data)) {
|
|
8
|
+
throw throwError(promiseErrorMessage);
|
|
9
|
+
}
|
|
10
|
+
return data;
|
|
11
|
+
};
|
|
6
12
|
|
|
7
13
|
export const syncDataLoaderFeature: FeatureImplementation = {
|
|
8
14
|
key: "sync-data-loader",
|
|
@@ -29,20 +35,21 @@ export const syncDataLoaderFeature: FeatureImplementation = {
|
|
|
29
35
|
waitForItemChildrenLoaded: async () => {},
|
|
30
36
|
|
|
31
37
|
retrieveItemData: ({ tree }, itemId) => {
|
|
32
|
-
|
|
33
|
-
if (typeof data === "object" && "then" in data) {
|
|
34
|
-
throw throwError(promiseErrorMessage);
|
|
35
|
-
}
|
|
36
|
-
return data;
|
|
38
|
+
return unpromise(tree.getConfig().dataLoader.getItem(itemId));
|
|
37
39
|
},
|
|
38
40
|
|
|
39
41
|
retrieveChildrenIds: ({ tree }, itemId) => {
|
|
40
|
-
const
|
|
41
|
-
if (
|
|
42
|
-
|
|
42
|
+
const { dataLoader } = tree.getConfig();
|
|
43
|
+
if ("getChildren" in dataLoader) {
|
|
44
|
+
return unpromise(dataLoader.getChildren(itemId));
|
|
43
45
|
}
|
|
44
|
-
return
|
|
46
|
+
return unpromise(dataLoader.getChildrenWithData(itemId)).map(
|
|
47
|
+
(c) => c.data,
|
|
48
|
+
);
|
|
45
49
|
},
|
|
50
|
+
|
|
51
|
+
loadItemData: ({ tree }, itemId) => tree.retrieveItemData(itemId),
|
|
52
|
+
loadChildrenIds: ({ tree }, itemId) => tree.retrieveChildrenIds(itemId),
|
|
46
53
|
},
|
|
47
54
|
|
|
48
55
|
itemInstance: {
|
|
@@ -1,7 +1,14 @@
|
|
|
1
|
-
export
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
1
|
+
export type TreeDataLoader<T> =
|
|
2
|
+
| {
|
|
3
|
+
getItem: (itemId: string) => T | Promise<T>;
|
|
4
|
+
getChildren: (itemId: string) => string[] | Promise<string[]>;
|
|
5
|
+
}
|
|
6
|
+
| {
|
|
7
|
+
getItem: (itemId: string) => T | Promise<T>;
|
|
8
|
+
getChildrenWithData: (
|
|
9
|
+
itemId: string,
|
|
10
|
+
) => { id: string; data: T }[] | Promise<{ id: string; data: T }[]>;
|
|
11
|
+
};
|
|
5
12
|
|
|
6
13
|
export type SyncDataLoaderFeatureDef<T> = {
|
|
7
14
|
state: {};
|
package/readme.md
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
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.
|