@headless-tree/core 1.0.1 → 1.2.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.
Files changed (58) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/lib/cjs/core/create-tree.js +13 -2
  3. package/lib/cjs/features/async-data-loader/feature.js +78 -68
  4. package/lib/cjs/features/async-data-loader/types.d.ts +12 -7
  5. package/lib/cjs/features/drag-and-drop/feature.js +1 -0
  6. package/lib/cjs/features/expand-all/feature.js +2 -2
  7. package/lib/cjs/features/hotkeys-core/feature.js +50 -18
  8. package/lib/cjs/features/hotkeys-core/types.d.ts +3 -0
  9. package/lib/cjs/features/keyboard-drag-and-drop/feature.js +1 -1
  10. package/lib/cjs/features/renaming/feature.js +8 -0
  11. package/lib/cjs/features/selection/feature.js +1 -1
  12. package/lib/cjs/features/sync-data-loader/feature.js +13 -9
  13. package/lib/cjs/features/sync-data-loader/types.d.ts +11 -2
  14. package/lib/cjs/features/tree/feature.js +1 -0
  15. package/lib/cjs/features/tree/types.d.ts +1 -0
  16. package/lib/cjs/index.d.ts +2 -0
  17. package/lib/cjs/index.js +5 -0
  18. package/lib/cjs/test-utils/test-tree-expect.js +1 -0
  19. package/lib/cjs/test-utils/test-tree.js +1 -0
  20. package/lib/esm/core/create-tree.js +13 -2
  21. package/lib/esm/features/async-data-loader/feature.js +78 -68
  22. package/lib/esm/features/async-data-loader/types.d.ts +12 -7
  23. package/lib/esm/features/drag-and-drop/feature.js +1 -0
  24. package/lib/esm/features/expand-all/feature.js +2 -2
  25. package/lib/esm/features/hotkeys-core/feature.js +50 -18
  26. package/lib/esm/features/hotkeys-core/types.d.ts +3 -0
  27. package/lib/esm/features/keyboard-drag-and-drop/feature.js +1 -1
  28. package/lib/esm/features/renaming/feature.js +8 -0
  29. package/lib/esm/features/selection/feature.js +1 -1
  30. package/lib/esm/features/sync-data-loader/feature.js +13 -9
  31. package/lib/esm/features/sync-data-loader/types.d.ts +11 -2
  32. package/lib/esm/features/tree/feature.js +1 -0
  33. package/lib/esm/features/tree/types.d.ts +1 -0
  34. package/lib/esm/index.d.ts +2 -0
  35. package/lib/esm/index.js +2 -0
  36. package/lib/esm/test-utils/test-tree-expect.js +1 -0
  37. package/lib/esm/test-utils/test-tree.js +1 -0
  38. package/package.json +7 -2
  39. package/readme.md +2 -2
  40. package/src/core/create-tree.ts +20 -2
  41. package/src/features/async-data-loader/async-data-loader.spec.ts +82 -0
  42. package/src/features/async-data-loader/feature.ts +92 -67
  43. package/src/features/async-data-loader/types.ts +14 -7
  44. package/src/features/drag-and-drop/feature.ts +1 -0
  45. package/src/features/expand-all/feature.ts +2 -2
  46. package/src/features/hotkeys-core/feature.ts +56 -17
  47. package/src/features/hotkeys-core/types.ts +4 -0
  48. package/src/features/keyboard-drag-and-drop/feature.ts +1 -1
  49. package/src/features/renaming/feature.ts +13 -0
  50. package/src/features/renaming/renaming.spec.ts +31 -0
  51. package/src/features/selection/feature.ts +1 -1
  52. package/src/features/sync-data-loader/feature.ts +16 -9
  53. package/src/features/sync-data-loader/types.ts +11 -4
  54. package/src/features/tree/feature.ts +1 -0
  55. package/src/features/tree/types.ts +1 -0
  56. package/src/index.ts +3 -0
  57. package/src/test-utils/test-tree-expect.ts +1 -0
  58. package/src/test-utils/test-tree.ts +1 -0
@@ -24,6 +24,24 @@ const verifyFeatures = (features: FeatureImplementation[] | undefined) => {
24
24
  }
25
25
  };
26
26
 
27
+ // Check all possible pairs and sort the array
28
+ const exhaustiveSort = <T>(
29
+ arr: T[],
30
+ compareFn: (param1: T, param2: T) => number,
31
+ ) => {
32
+ const n = arr.length;
33
+
34
+ for (let i = 0; i < n; i++) {
35
+ for (let j = i + 1; j < n; j++) {
36
+ if (compareFn(arr[j], arr[i]) < 0) {
37
+ [arr[i], arr[j]] = [arr[j], arr[i]];
38
+ }
39
+ }
40
+ }
41
+
42
+ return arr;
43
+ };
44
+
27
45
  const compareFeatures =
28
46
  (originalOrder: FeatureImplementation[]) =>
29
47
  (feature1: FeatureImplementation, feature2: FeatureImplementation) => {
@@ -33,11 +51,12 @@ const compareFeatures =
33
51
  if (feature1.key && feature2.overwrites?.includes(feature1.key)) {
34
52
  return -1;
35
53
  }
54
+
36
55
  return originalOrder.indexOf(feature1) - originalOrder.indexOf(feature2);
37
56
  };
38
57
 
39
58
  const sortFeatures = (features: FeatureImplementation[] = []) =>
40
- features.sort(compareFeatures(features));
59
+ exhaustiveSort(features, compareFeatures(features));
41
60
 
42
61
  export const createTree = <T>(
43
62
  initialConfig: TreeConfig<T>,
@@ -228,7 +247,6 @@ export const createTree = <T>(
228
247
  }
229
248
 
230
249
  finalizeTree();
231
- rebuildItemMeta();
232
250
 
233
251
  return treeInstance;
234
252
  };
@@ -86,6 +86,7 @@ describe("core-feature/selections", () => {
86
86
  suiteTree.resetBeforeEach();
87
87
 
88
88
  it("invalidates item data on item instance", async () => {
89
+ const setLoadingItemData = suiteTree.mockedHandler("setLoadingItemData");
89
90
  getItem.mockClear();
90
91
  await suiteTree.resolveAsyncVisibleItems();
91
92
  getItem.mockResolvedValueOnce("new");
@@ -93,9 +94,14 @@ describe("core-feature/selections", () => {
93
94
  await suiteTree.resolveAsyncVisibleItems();
94
95
  expect(getItem).toHaveBeenCalledWith("x1");
95
96
  expect(suiteTree.item("x1").getItemData()).toBe("new");
97
+ expect(setLoadingItemData).toBeCalledWith(["x1"]);
98
+ expect(setLoadingItemData).toBeCalledWith([]);
96
99
  });
97
100
 
98
101
  it("invalidates children ids on item instance", async () => {
102
+ const setLoadingItemChildrens = suiteTree.mockedHandler(
103
+ "setLoadingItemChildrens",
104
+ );
99
105
  getChildren.mockClear();
100
106
  await suiteTree.resolveAsyncVisibleItems();
101
107
  getChildren.mockResolvedValueOnce(["new1", "new2"]);
@@ -103,6 +109,8 @@ describe("core-feature/selections", () => {
103
109
  await suiteTree.resolveAsyncVisibleItems();
104
110
  expect(getChildren).toHaveBeenCalledWith("x1");
105
111
  suiteTree.expect.hasChildren("x1", ["new1", "new2"]);
112
+ expect(setLoadingItemChildrens).toBeCalledWith(["x1"]);
113
+ expect(setLoadingItemChildrens).toBeCalledWith([]);
106
114
  });
107
115
 
108
116
  it("doesnt call item data getter twice", async () => {
@@ -124,5 +132,79 @@ describe("core-feature/selections", () => {
124
132
  suiteTree.expect.hasChildren("x1", ["x11", "x12", "x13", "x14"]);
125
133
  expect(getChildren).toHaveBeenCalledTimes(1);
126
134
  });
135
+
136
+ it("optimistic invalidates item data on item instance", async () => {
137
+ const setLoadingItemData = suiteTree.mockedHandler("setLoadingItemData");
138
+ getItem.mockClear();
139
+ await suiteTree.resolveAsyncVisibleItems();
140
+ getItem.mockResolvedValueOnce("new");
141
+ suiteTree.item("x1").invalidateItemData(true);
142
+ await suiteTree.resolveAsyncVisibleItems();
143
+ expect(getItem).toHaveBeenCalledWith("x1");
144
+ expect(suiteTree.item("x1").getItemData()).toBe("new");
145
+ expect(setLoadingItemData).toBeCalledTimes(1);
146
+ expect(setLoadingItemData).toBeCalledWith([]);
147
+ });
148
+
149
+ it("optimistic invalidates children ids on item instance", async () => {
150
+ const setLoadingItemChildrens = suiteTree.mockedHandler(
151
+ "setLoadingItemChildrens",
152
+ );
153
+ getChildren.mockClear();
154
+ await suiteTree.resolveAsyncVisibleItems();
155
+ getChildren.mockResolvedValueOnce(["new1", "new2"]);
156
+ suiteTree.item("x1").invalidateChildrenIds(true);
157
+ await suiteTree.resolveAsyncVisibleItems();
158
+ expect(getChildren).toHaveBeenCalledWith("x1");
159
+ suiteTree.expect.hasChildren("x1", ["new1", "new2"]);
160
+ expect(setLoadingItemChildrens).toBeCalledTimes(1);
161
+ expect(setLoadingItemChildrens).toBeCalledWith([]);
162
+ });
163
+ });
164
+
165
+ describe("getChildrenWithData", () => {
166
+ const getChildrenWithData = vi.fn(async (id) => [
167
+ { id: `${id}1`, data: `${id}1-data` },
168
+ { id: `${id}2`, data: `${id}2-data` },
169
+ ]);
170
+ const getItem = vi.fn();
171
+ const suiteTree = tree.with({
172
+ dataLoader: { getItem, getChildrenWithData },
173
+ });
174
+ suiteTree.resetBeforeEach();
175
+
176
+ it("loads children with data", async () => {
177
+ getChildrenWithData.mockClear();
178
+ suiteTree.do.selectItem("x12");
179
+ await suiteTree.resolveAsyncVisibleItems();
180
+ expect(getChildrenWithData).toHaveBeenCalledWith("x12");
181
+ suiteTree.expect.hasChildren("x12", ["x121", "x122"]);
182
+ expect(suiteTree.item("x121").getItemData()).toBe("x121-data");
183
+ expect(suiteTree.item("x122").getItemData()).toBe("x122-data");
184
+ });
185
+
186
+ it.skip("invalidates children and reloads with data", async () => {
187
+ await suiteTree.resolveAsyncVisibleItems();
188
+ suiteTree.item("x").invalidateChildrenIds();
189
+ getChildrenWithData.mockResolvedValueOnce([
190
+ { id: "new1", data: "new1-data" },
191
+ { id: "new2", data: "new2-data" },
192
+ ]);
193
+ getChildrenWithData.mockClear();
194
+ await suiteTree.resolveAsyncVisibleItems();
195
+ expect(getChildrenWithData).toHaveBeenCalledTimes(1);
196
+ suiteTree.expect.hasChildren("x", ["new1", "new2"]);
197
+ expect(suiteTree.item("new1").getItemData()).toBe("new1-data");
198
+ expect(suiteTree.item("new2").getItemData()).toBe("new2-data");
199
+ });
200
+
201
+ it("does not call getChildrenWithData twice unnecessarily", async () => {
202
+ await suiteTree.resolveAsyncVisibleItems();
203
+ getChildrenWithData.mockClear();
204
+ suiteTree.item("x").invalidateChildrenIds();
205
+ await suiteTree.resolveAsyncVisibleItems();
206
+ suiteTree.expect.hasChildren("x", ["x1", "x2"]);
207
+ expect(getChildrenWithData).toHaveBeenCalledTimes(1);
208
+ });
127
209
  });
128
210
  });
@@ -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: async ({ tree }, itemId) => {
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: async ({ tree }, itemId) => {
40
- tree.retrieveChildrenIds(itemId);
41
- if (!tree.getState().loadingItemChildrens.includes(itemId)) {
42
- return;
43
- }
44
- await new Promise<void>((resolve) => {
45
- const dataRef = tree.getDataRef<AsyncDataLoaderDataRef>();
46
- dataRef.current.awaitingItemChildrensLoading ??= {};
47
- dataRef.current.awaitingItemChildrensLoading[itemId] ??= [];
48
- dataRef.current.awaitingItemChildrensLoading[itemId].push(resolve);
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 = tree.getDataRef<AsyncDataLoaderDataRef>();
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
- (async () => {
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 config = tree.getConfig();
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
- (async () => {
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
- const dataRef = tree.getDataRef<AsyncDataLoaderDataRef>();
130
- delete dataRef.current.itemData?.[itemId];
131
- tree.retrieveItemData(itemId);
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
- const dataRef = tree.getDataRef<AsyncDataLoaderDataRef>();
135
- delete dataRef.current.childrenIds?.[itemId];
136
- tree.retrieveChildrenIds(itemId);
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
- invalidateItemData: () => void;
41
- invalidateChildrenIds: () => void;
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.key === "Escape") {
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
- Letter: /^[a-z]$/,
10
- LetterOrNumber: /^[a-z0-9]$/,
11
- Plus: /^\+$/,
12
- Space: /^ $/,
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
- ? [...pressedKeys].some((pressedKey) => specialKeys[key].test(pressedKey))
24
- : pressedKeys.has(key),
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 key = e.key.toLowerCase();
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(key);
51
- data.current.pressedKeys.add(key);
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
- tree.getConfig().hotkeys as HotkeysConfig<any>,
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
- ...tree.getConfig().hotkeys?.[hotkeyName],
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
- tree.getConfig().onTreeHotkey?.(hotkeyName, e);
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.key.toLowerCase());
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+D",
190
+ hotkey: "Control+Shift+KeyD",
191
191
  preventDefault: true,
192
192
  isEnabled: (tree) => !tree.getState().dnd,
193
193
  handler: (_, tree) => {
@@ -9,6 +9,7 @@ type InputEvent = {
9
9
 
10
10
  export const renamingFeature: FeatureImplementation = {
11
11
  key: "renaming",
12
+ overwrites: ["drag-and-drop"],
12
13
 
13
14
  getDefaultConfig: (defaultConfig, tree) => ({
14
15
  setRenamingItem: makeStateUpdater("renamingItem", tree),
@@ -72,6 +73,18 @@ export const renamingFeature: FeatureImplementation = {
72
73
 
73
74
  isRenaming: ({ tree, item }) =>
74
75
  item.getId() === tree.getState().renamingItem,
76
+
77
+ getProps: ({ prev, item }) => {
78
+ const isRenaming = item.isRenaming();
79
+ const prevProps = prev?.() ?? {};
80
+ return isRenaming
81
+ ? {
82
+ ...prevProps,
83
+ draggable: false,
84
+ onDragStart: () => {},
85
+ }
86
+ : prevProps;
87
+ },
75
88
  },
76
89
 
77
90
  hotkeys: {
@@ -93,6 +93,37 @@ describe("core-feature/renaming", () => {
93
93
  expect(setRenamingItem).toHaveBeenCalledWith(null);
94
94
  });
95
95
 
96
+ describe("dragging", async () => {
97
+ const suiteTree = await tree
98
+ .withFeatures({
99
+ key: "drag-and-drop",
100
+ itemInstance: {
101
+ getProps: ({ prev }: any) => ({
102
+ ...prev?.(),
103
+ draggable: true,
104
+ onDragStart: "initialOnDragStart",
105
+ }),
106
+ },
107
+ })
108
+ .createTestCaseTree();
109
+ suiteTree.resetBeforeEach();
110
+
111
+ it("sets draggable to undefined for items being renamed", () => {
112
+ const item = suiteTree.item("x1");
113
+ item.startRenaming();
114
+ const props = item.getProps();
115
+ expect(props.draggable).toBe(false);
116
+ expect(props.onDragStart).toStrictEqual(expect.any(Function));
117
+ });
118
+
119
+ it("retains draggable for items not being renamed", () => {
120
+ const item = suiteTree.item("x1");
121
+ const props = item.getProps();
122
+ expect(props.draggable).toBe(true);
123
+ expect(props.onDragStart).toBe("initialOnDragStart");
124
+ });
125
+ });
126
+
96
127
  describe("hotkeys", () => {
97
128
  it("starts renaming", () => {
98
129
  const setRenamingItem = tree.mockedHandler("setRenamingItem");
@@ -144,7 +144,7 @@ export const selectionFeature: FeatureImplementation = {
144
144
  },
145
145
  },
146
146
  selectAll: {
147
- hotkey: "Control+a",
147
+ hotkey: "Control+KeyA",
148
148
  preventDefault: true,
149
149
  handler: (e, tree) => {
150
150
  tree.setSelectedItems(tree.getItems().map((item) => item.getId()));