@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.
Files changed (37) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/lib/cjs/features/async-data-loader/feature.js +78 -68
  3. package/lib/cjs/features/async-data-loader/types.d.ts +12 -7
  4. package/lib/cjs/features/drag-and-drop/feature.js +1 -0
  5. package/lib/cjs/features/expand-all/feature.js +2 -2
  6. package/lib/cjs/features/hotkeys-core/feature.js +50 -18
  7. package/lib/cjs/features/hotkeys-core/types.d.ts +3 -0
  8. package/lib/cjs/features/keyboard-drag-and-drop/feature.js +1 -1
  9. package/lib/cjs/features/selection/feature.js +1 -1
  10. package/lib/cjs/features/sync-data-loader/feature.js +13 -9
  11. package/lib/cjs/features/sync-data-loader/types.d.ts +11 -2
  12. package/lib/cjs/test-utils/test-tree-expect.js +1 -0
  13. package/lib/esm/features/async-data-loader/feature.js +78 -68
  14. package/lib/esm/features/async-data-loader/types.d.ts +12 -7
  15. package/lib/esm/features/drag-and-drop/feature.js +1 -0
  16. package/lib/esm/features/expand-all/feature.js +2 -2
  17. package/lib/esm/features/hotkeys-core/feature.js +50 -18
  18. package/lib/esm/features/hotkeys-core/types.d.ts +3 -0
  19. package/lib/esm/features/keyboard-drag-and-drop/feature.js +1 -1
  20. package/lib/esm/features/selection/feature.js +1 -1
  21. package/lib/esm/features/sync-data-loader/feature.js +13 -9
  22. package/lib/esm/features/sync-data-loader/types.d.ts +11 -2
  23. package/lib/esm/test-utils/test-tree-expect.js +1 -0
  24. package/package.json +1 -1
  25. package/src/features/async-data-loader/async-data-loader.spec.ts +82 -0
  26. package/src/features/async-data-loader/feature.ts +92 -67
  27. package/src/features/async-data-loader/types.ts +14 -7
  28. package/src/features/drag-and-drop/feature.ts +1 -0
  29. package/src/features/expand-all/feature.ts +2 -2
  30. package/src/features/hotkeys-core/feature.ts +56 -17
  31. package/src/features/hotkeys-core/types.ts +4 -0
  32. package/src/features/keyboard-drag-and-drop/feature.ts +1 -1
  33. package/src/features/selection/feature.ts +1 -1
  34. package/src/features/sync-data-loader/feature.ts +16 -9
  35. package/src/features/sync-data-loader/types.ts +11 -4
  36. package/src/test-utils/test-tree-expect.ts +1 -0
  37. 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: 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) => {
@@ -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()));
@@ -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
- const data = tree.getConfig().dataLoader.getItem(itemId);
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 data = tree.getConfig().dataLoader.getChildren(itemId);
41
- if (typeof data === "object" && "then" in data) {
42
- throw throwError(promiseErrorMessage);
42
+ const { dataLoader } = tree.getConfig();
43
+ if ("getChildren" in dataLoader) {
44
+ return unpromise(dataLoader.getChildren(itemId));
43
45
  }
44
- return data;
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 interface TreeDataLoader<T> {
2
- getItem: (itemId: string) => T | Promise<T>;
3
- getChildren: (itemId: string) => string[] | Promise<string[]>;
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: {};
@@ -82,6 +82,7 @@ export class TestTreeExpect<T> {
82
82
  top: 0,
83
83
  });
84
84
  expect(this.tree.instance.getDragLineStyle(0, 0)).toEqual({
85
+ position: "absolute",
85
86
  left: `${indent * 20}px`,
86
87
  pointerEvents: "none",
87
88
  top: "0px",
package/readme.md DELETED
@@ -1,157 +0,0 @@
1
- ![Headless Tree](./packages/docs/static/img/banner-github.png)
2
-
3
- [![Documentation](https://img.shields.io/badge/docs-1e1f22?style=flat)](https://headless-tree.lukasbach.com/)
4
- [![Chat on Discord](https://img.shields.io/badge/discord-4c57d9?style=flat&logo=discord&logoColor=ffffff)](https://discord.gg/KuZ6EezzVw)
5
- [![Follow on BLuesky](https://img.shields.io/badge/bluesky-0285FF?style=flat&logo=bluesky&logoColor=ffffff)](https://bsky.app/profile/lukasbach.bsky.social)
6
- [![Support on Github Sponsors](https://img.shields.io/badge/sponsor-EA4AAA?style=flat&logo=githubsponsors&logoColor=ffffff)](https://github.com/sponsors/lukasbach)
7
- [![Follow on Github](https://img.shields.io/badge/follow-181717?style=flat&logo=github&logoColor=ffffff)](https://github.com/lukasbach)
8
- [![NPM Core package](https://img.shields.io/badge/core-CB3837?style=flat&logo=npm&logoColor=ffffff)](https://www.npmjs.com/package/@headless-tree/core)
9
- [![NPM React package](https://img.shields.io/badge/react-CB3837?style=flat&logo=npm&logoColor=ffffff)](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.