@headless-tree/core 0.0.10 → 0.0.11

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 (114) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/lib/cjs/core/build-proxified-instance.d.ts +2 -0
  3. package/lib/cjs/core/build-proxified-instance.js +58 -0
  4. package/lib/cjs/core/build-static-instance.d.ts +2 -0
  5. package/lib/cjs/core/build-static-instance.js +27 -0
  6. package/lib/cjs/core/create-tree.js +55 -36
  7. package/lib/cjs/features/async-data-loader/feature.js +37 -23
  8. package/lib/cjs/features/async-data-loader/types.d.ts +2 -1
  9. package/lib/cjs/features/drag-and-drop/feature.js +64 -32
  10. package/lib/cjs/features/drag-and-drop/types.d.ts +13 -4
  11. package/lib/cjs/features/drag-and-drop/utils.d.ts +1 -2
  12. package/lib/cjs/features/drag-and-drop/utils.js +140 -37
  13. package/lib/cjs/features/expand-all/feature.js +12 -6
  14. package/lib/cjs/features/main/types.d.ts +8 -2
  15. package/lib/cjs/features/renaming/feature.js +33 -18
  16. package/lib/cjs/features/renaming/types.d.ts +1 -1
  17. package/lib/cjs/features/search/feature.js +38 -24
  18. package/lib/cjs/features/search/types.d.ts +0 -1
  19. package/lib/cjs/features/selection/feature.js +23 -14
  20. package/lib/cjs/features/sync-data-loader/feature.js +7 -2
  21. package/lib/cjs/features/tree/feature.d.ts +2 -1
  22. package/lib/cjs/features/tree/feature.js +85 -63
  23. package/lib/cjs/features/tree/types.d.ts +5 -3
  24. package/lib/cjs/index.d.ts +3 -1
  25. package/lib/cjs/index.js +2 -1
  26. package/lib/cjs/test-utils/test-tree-do.d.ts +23 -0
  27. package/lib/cjs/test-utils/test-tree-do.js +99 -0
  28. package/lib/cjs/test-utils/test-tree-expect.d.ts +15 -0
  29. package/lib/cjs/test-utils/test-tree-expect.js +62 -0
  30. package/lib/cjs/test-utils/test-tree.d.ts +47 -0
  31. package/lib/cjs/test-utils/test-tree.js +195 -0
  32. package/lib/cjs/types/core.d.ts +31 -15
  33. package/lib/cjs/utilities/errors.d.ts +1 -0
  34. package/lib/cjs/utilities/errors.js +5 -0
  35. package/lib/cjs/utilities/insert-items-at-target.js +10 -3
  36. package/lib/cjs/utilities/remove-items-from-parents.js +14 -8
  37. package/lib/cjs/utils.d.ts +3 -3
  38. package/lib/cjs/utils.js +6 -6
  39. package/lib/esm/core/build-proxified-instance.d.ts +2 -0
  40. package/lib/esm/core/build-proxified-instance.js +54 -0
  41. package/lib/esm/core/build-static-instance.d.ts +2 -0
  42. package/lib/esm/core/build-static-instance.js +23 -0
  43. package/lib/esm/core/create-tree.js +55 -36
  44. package/lib/esm/features/async-data-loader/feature.js +37 -23
  45. package/lib/esm/features/async-data-loader/types.d.ts +2 -1
  46. package/lib/esm/features/drag-and-drop/feature.js +64 -32
  47. package/lib/esm/features/drag-and-drop/types.d.ts +13 -4
  48. package/lib/esm/features/drag-and-drop/utils.d.ts +1 -2
  49. package/lib/esm/features/drag-and-drop/utils.js +138 -34
  50. package/lib/esm/features/expand-all/feature.js +12 -6
  51. package/lib/esm/features/main/types.d.ts +8 -2
  52. package/lib/esm/features/renaming/feature.js +33 -18
  53. package/lib/esm/features/renaming/types.d.ts +1 -1
  54. package/lib/esm/features/search/feature.js +38 -24
  55. package/lib/esm/features/search/types.d.ts +0 -1
  56. package/lib/esm/features/selection/feature.js +23 -14
  57. package/lib/esm/features/sync-data-loader/feature.js +7 -2
  58. package/lib/esm/features/tree/feature.d.ts +2 -1
  59. package/lib/esm/features/tree/feature.js +86 -64
  60. package/lib/esm/features/tree/types.d.ts +5 -3
  61. package/lib/esm/index.d.ts +3 -1
  62. package/lib/esm/index.js +2 -1
  63. package/lib/esm/test-utils/test-tree-do.d.ts +23 -0
  64. package/lib/esm/test-utils/test-tree-do.js +95 -0
  65. package/lib/esm/test-utils/test-tree-expect.d.ts +15 -0
  66. package/lib/esm/test-utils/test-tree-expect.js +58 -0
  67. package/lib/esm/test-utils/test-tree.d.ts +47 -0
  68. package/lib/esm/test-utils/test-tree.js +191 -0
  69. package/lib/esm/types/core.d.ts +31 -15
  70. package/lib/esm/utilities/errors.d.ts +1 -0
  71. package/lib/esm/utilities/errors.js +1 -0
  72. package/lib/esm/utilities/insert-items-at-target.js +10 -3
  73. package/lib/esm/utilities/remove-items-from-parents.js +14 -8
  74. package/lib/esm/utils.d.ts +3 -3
  75. package/lib/esm/utils.js +3 -3
  76. package/package.json +7 -3
  77. package/src/core/build-proxified-instance.ts +115 -0
  78. package/src/core/build-static-instance.ts +28 -0
  79. package/src/core/create-tree.ts +60 -62
  80. package/src/features/async-data-loader/async-data-loader.spec.ts +143 -0
  81. package/src/features/async-data-loader/feature.ts +33 -31
  82. package/src/features/async-data-loader/types.ts +3 -1
  83. package/src/features/drag-and-drop/drag-and-drop.spec.ts +716 -0
  84. package/src/features/drag-and-drop/feature.ts +109 -85
  85. package/src/features/drag-and-drop/types.ts +21 -7
  86. package/src/features/drag-and-drop/utils.ts +196 -55
  87. package/src/features/expand-all/expand-all.spec.ts +52 -0
  88. package/src/features/expand-all/feature.ts +8 -12
  89. package/src/features/hotkeys-core/feature.ts +1 -1
  90. package/src/features/main/types.ts +14 -1
  91. package/src/features/renaming/feature.ts +30 -29
  92. package/src/features/renaming/renaming.spec.ts +125 -0
  93. package/src/features/renaming/types.ts +1 -1
  94. package/src/features/search/feature.ts +34 -38
  95. package/src/features/search/search.spec.ts +115 -0
  96. package/src/features/search/types.ts +0 -1
  97. package/src/features/selection/feature.ts +29 -30
  98. package/src/features/selection/selection.spec.ts +220 -0
  99. package/src/features/sync-data-loader/feature.ts +8 -11
  100. package/src/features/tree/feature.ts +82 -87
  101. package/src/features/tree/tree.spec.ts +515 -0
  102. package/src/features/tree/types.ts +5 -3
  103. package/src/index.ts +4 -1
  104. package/src/test-utils/test-tree-do.ts +136 -0
  105. package/src/test-utils/test-tree-expect.ts +86 -0
  106. package/src/test-utils/test-tree.ts +217 -0
  107. package/src/types/core.ts +92 -33
  108. package/src/utilities/errors.ts +2 -0
  109. package/src/utilities/insert-items-at-target.ts +10 -3
  110. package/src/utilities/remove-items-from-parents.ts +15 -10
  111. package/src/utils.spec.ts +89 -0
  112. package/src/utils.ts +6 -6
  113. package/tsconfig.json +1 -0
  114. package/vitest.config.ts +6 -0
@@ -0,0 +1,86 @@
1
+ /* eslint-disable import/no-extraneous-dependencies */
2
+ import { Mock, expect } from "vitest";
3
+ import { DragEvent } from "react";
4
+ import { TestTree } from "./test-tree";
5
+ import { DropTarget } from "../features/drag-and-drop/types";
6
+
7
+ export class TestTreeExpect<T> {
8
+ protected itemInstance(itemId: string) {
9
+ return this.tree.instance.getItemInstance(itemId);
10
+ }
11
+
12
+ protected itemProps(itemId: string) {
13
+ return this.itemInstance(itemId).getProps();
14
+ }
15
+
16
+ constructor(private tree: TestTree<T>) {}
17
+
18
+ foldersExpanded(...itemIds: string[]) {
19
+ for (const itemId of itemIds) {
20
+ expect(
21
+ this.tree.instance.getItemInstance(itemId).isExpanded(),
22
+ `Expected ${itemId} to be expanded`,
23
+ ).toBe(true);
24
+ }
25
+ }
26
+
27
+ foldersCollapsed(...itemIds: string[]) {
28
+ for (const itemId of itemIds) {
29
+ expect(
30
+ this.tree.instance.getItemInstance(itemId).isExpanded(),
31
+ `Expected ${itemId} to be collapsed`,
32
+ ).toBe(false);
33
+ }
34
+ }
35
+
36
+ hasChildren(itemId: string, children: string[]) {
37
+ const item = this.tree.instance.getItemInstance(itemId);
38
+ const itemChildren = item.getChildren().map((child) => child.getId());
39
+ expect(itemChildren).toEqual(children);
40
+ }
41
+
42
+ dropped(draggedItems: string[], target: DropTarget<any>) {
43
+ expect(this.tree.instance.getConfig().onDrop).toBeCalledWith(
44
+ draggedItems.map((id) => this.tree.item(id)),
45
+ target,
46
+ );
47
+ }
48
+
49
+ dragOverNotAllowed(itemId: string, event?: DragEvent) {
50
+ const e = event ?? TestTree.dragEvent();
51
+ (e.preventDefault as Mock).mockClear();
52
+ this.itemProps(itemId).onDragOver(e);
53
+ this.itemProps(itemId).onDragOver(e);
54
+ this.itemProps(itemId).onDragOver(e);
55
+ expect(
56
+ e.preventDefault,
57
+ "onDragOver shouldn't call e.preventDefault if drag is not allowed",
58
+ ).not.toBeCalled();
59
+
60
+ this.itemProps(itemId).onDrop(e);
61
+ expect(
62
+ e.preventDefault,
63
+ "onDrop shouldn't call e.preventDefault if drag is not allowed",
64
+ ).not.toBeCalled();
65
+ expect(
66
+ this.tree.instance.getConfig().onDrop,
67
+ "onDrop handler shouldn't be called if drag is not allowed",
68
+ ).not.toBeCalled();
69
+ return e;
70
+ }
71
+
72
+ defaultDragLineProps(indent = 0) {
73
+ expect(this.tree.instance.getDragLineData()).toEqual({
74
+ indent,
75
+ left: indent * 20,
76
+ right: 100,
77
+ top: 0,
78
+ });
79
+ expect(this.tree.instance.getDragLineStyle(0, 0)).toEqual({
80
+ left: `${indent * 20}px`,
81
+ pointerEvents: "none",
82
+ top: "0px",
83
+ width: `${100 - indent * 20}px`,
84
+ });
85
+ }
86
+ }
@@ -0,0 +1,217 @@
1
+ /* eslint-disable import/no-extraneous-dependencies */
2
+ import { beforeEach, describe, vi } from "vitest";
3
+ import { DragEvent } from "react";
4
+ import { TreeConfig, TreeInstance } from "../types/core";
5
+ import { createTree } from "../core/create-tree";
6
+ import { TestTreeDo } from "./test-tree-do";
7
+ import { TestTreeExpect } from "./test-tree-expect";
8
+ import { syncDataLoaderFeature } from "../features/sync-data-loader/feature";
9
+ import { asyncDataLoaderFeature } from "../features/async-data-loader/feature";
10
+ import { buildProxiedInstance } from "../core/build-proxified-instance";
11
+
12
+ vi.useFakeTimers({ shouldAdvanceTime: true });
13
+
14
+ export class TestTree<T = string> {
15
+ public readonly do = new TestTreeDo(this);
16
+
17
+ public readonly expect = new TestTreeExpect(this);
18
+
19
+ private treeInstance: TreeInstance<T> | null = null;
20
+
21
+ private static asyncLoaderResolvers: (() => void)[] = [];
22
+
23
+ suits = {
24
+ sync: () => ({
25
+ tree: this.withFeatures(syncDataLoaderFeature),
26
+ title: "Synchronous Data Loader",
27
+ }),
28
+ async: () => ({
29
+ tree: this.withFeatures(asyncDataLoaderFeature),
30
+ title: "Asynchronous Data Loader",
31
+ }),
32
+ proxifiedSync: () => ({
33
+ tree: this.withFeatures(syncDataLoaderFeature).with({
34
+ instanceBuilder: buildProxiedInstance,
35
+ }),
36
+ title: "Proxified Synchronous Data Loader",
37
+ }),
38
+ proxifiedAsync: () => ({
39
+ tree: this.withFeatures(asyncDataLoaderFeature).with({
40
+ instanceBuilder: buildProxiedInstance,
41
+ }),
42
+ title: "Proxified Asynchronous Data Loader",
43
+ }),
44
+ };
45
+
46
+ forSuits(runSuite: (tree: TestTree<T>) => void) {
47
+ describe.for([
48
+ this.suits.sync(),
49
+ this.suits.async(),
50
+ this.suits.proxifiedSync(),
51
+ this.suits.proxifiedAsync(),
52
+ ])("$title", ({ tree }) => {
53
+ tree.resetBeforeEach();
54
+ runSuite(tree);
55
+ });
56
+ }
57
+
58
+ get instance() {
59
+ if (!this.treeInstance) {
60
+ this.treeInstance = createTree(this.config);
61
+ }
62
+ return this.treeInstance;
63
+ }
64
+
65
+ private constructor(private config: TreeConfig<T>) {}
66
+
67
+ static async resolveAsyncLoaders() {
68
+ while (TestTree.asyncLoaderResolvers.length) {
69
+ TestTree.asyncLoaderResolvers.shift()?.();
70
+ await new Promise<void>((r) => {
71
+ setTimeout(r);
72
+ });
73
+ }
74
+ }
75
+
76
+ async resolveAsyncVisibleItems() {
77
+ this.instance.getItems();
78
+ await TestTree.resolveAsyncLoaders();
79
+ this.instance.getItems().forEach((i) => i.getItemName());
80
+ await TestTree.resolveAsyncLoaders();
81
+ }
82
+
83
+ static default(config: Partial<TreeConfig<string>>) {
84
+ return new TestTree({
85
+ rootItemId: "x",
86
+ createLoadingItemData: () => "loading",
87
+ dataLoader: {
88
+ getItem: (id) => id,
89
+ getChildren: (id) => [`${id}1`, `${id}2`, `${id}3`, `${id}4`],
90
+ },
91
+ asyncDataLoader: {
92
+ getItem: async (id) => {
93
+ await new Promise<void>((r) => {
94
+ (r as any).debugName = `Loading getItem ${id}`;
95
+ TestTree.asyncLoaderResolvers.push(r);
96
+ });
97
+ return id;
98
+ },
99
+ getChildren: async (id) => {
100
+ await new Promise<void>((r) => {
101
+ (r as any).debugName = `Loading getChildren ${id}`;
102
+ TestTree.asyncLoaderResolvers.push(r);
103
+ });
104
+ return [`${id}1`, `${id}2`, `${id}3`, `${id}4`];
105
+ },
106
+ },
107
+ getItemName: (item) => item.getItemData(),
108
+ indent: 20,
109
+ isItemFolder: (item) => item.getItemMeta().level < 2,
110
+ initialState: {
111
+ expandedItems: ["x1", "x11"],
112
+ },
113
+ features: [],
114
+ ...config,
115
+ });
116
+ }
117
+
118
+ with(config: Partial<TreeConfig<T>>) {
119
+ return new TestTree({ ...this.config, ...config });
120
+ }
121
+
122
+ resetBeforeEach() {
123
+ beforeEach(async () => {
124
+ await this.createTestCaseTree();
125
+ });
126
+ }
127
+
128
+ async createTestCaseTree() {
129
+ this.reset();
130
+ vi.clearAllMocks();
131
+ // trigger instance creation
132
+ // eslint-disable-next-line @typescript-eslint/no-unused-expressions
133
+ this.instance;
134
+ await this.resolveAsyncVisibleItems();
135
+ return this;
136
+ }
137
+
138
+ withFeatures(...features: any) {
139
+ return this.with({
140
+ features: [...(this.config.features ?? []), ...features],
141
+ });
142
+ }
143
+
144
+ mockedHandler(handlerName: keyof TreeConfig<T>) {
145
+ const mock = vi.fn();
146
+ if (this.treeInstance) {
147
+ this.treeInstance?.setConfig((prev) => ({
148
+ ...prev,
149
+ [handlerName as any]: mock,
150
+ }));
151
+ } else {
152
+ (this.config as any)[handlerName as any] = mock;
153
+ }
154
+ return mock;
155
+ }
156
+
157
+ item(itemId: string) {
158
+ return this.instance.getItemInstance(itemId);
159
+ }
160
+
161
+ reset() {
162
+ this.treeInstance = null;
163
+ TestTree.asyncLoaderResolvers = [];
164
+ }
165
+
166
+ debug() {
167
+ console.log(
168
+ this.instance
169
+ .getItems()
170
+ .map((item) =>
171
+ [
172
+ " ".repeat(item.getItemMeta().level),
173
+ '"',
174
+ item.getItemName(),
175
+ '"',
176
+ ].join(""),
177
+ )
178
+ .join("\n"),
179
+ );
180
+ }
181
+
182
+ setElementBoundingBox(
183
+ itemId: string,
184
+ bb: Partial<DOMRect> = {
185
+ left: 0,
186
+ right: 100,
187
+ top: 0,
188
+ height: 20,
189
+ },
190
+ ) {
191
+ this.instance.getItemInstance(itemId).registerElement({
192
+ getBoundingClientRect: () => bb as DOMRect,
193
+ } as HTMLElement);
194
+ }
195
+
196
+ static dragEvent(pageX = 1000, pageY = 0) {
197
+ return {
198
+ preventDefault: vi.fn(),
199
+ stopPropagation: vi.fn(),
200
+ dataTransfer: {
201
+ setData: vi.fn(),
202
+ getData: vi.fn(),
203
+ dropEffect: "unchaged-from-test",
204
+ },
205
+ pageX,
206
+ pageY,
207
+ } as unknown as DragEvent;
208
+ }
209
+
210
+ createTopDragEvent(indent = 0) {
211
+ return TestTree.dragEvent(indent * 20, 1);
212
+ }
213
+
214
+ createBottomDragEvent(indent = 0) {
215
+ return TestTree.dragEvent(indent * 20, 19);
216
+ }
217
+ }
package/src/types/core.ts CHANGED
@@ -15,11 +15,13 @@ import { ExpandAllFeatureDef } from "../features/expand-all/types";
15
15
  export type Updater<T> = T | ((old: T) => T);
16
16
  export type SetStateFn<T> = (updaterOrValue: Updater<T>) => void;
17
17
 
18
+ type FunctionMap = Record<string, (...args: any[]) => any>;
19
+
18
20
  export type FeatureDef = {
19
- state: object;
20
- config: object;
21
- treeInstance: object;
22
- itemInstance: object;
21
+ state: any;
22
+ config: any;
23
+ treeInstance: FunctionMap;
24
+ itemInstance: FunctionMap;
23
25
  hotkeys: string;
24
26
  };
25
27
 
@@ -52,7 +54,8 @@ export type FeatureDefs<T> =
52
54
  | ExpandAllFeatureDef;
53
55
 
54
56
  type MergedFeatures<F extends FeatureDef> = {
55
- // TODO remove in favor of types below
57
+ // type can't be removed because it's used for individual feature sets as feature deps in feature implementations
58
+ // to my future self, yes I'm already aware this sounds dumb when I first write this
56
59
  state: UnionToIntersection<F["state"]>;
57
60
  config: UnionToIntersection<F["config"]>;
58
61
  treeInstance: UnionToIntersection<F["treeInstance"]>;
@@ -123,62 +126,118 @@ export type CustomHotkeysConfig<
123
126
  Record<HotkeyName<F> | `custom${string}`, Partial<HotkeyConfig<T>>>
124
127
  >;
125
128
 
129
+ type MayReturnNull<T extends (...x: any[]) => any> = (
130
+ ...args: Parameters<T>
131
+ ) => ReturnType<T> | null;
132
+
133
+ export type ItemInstanceOpts<
134
+ ItemInstance extends FunctionMap = FunctionMap,
135
+ TreeInstance extends FunctionMap = FunctionMap,
136
+ Key extends keyof ItemInstance = any,
137
+ > = {
138
+ item: ItemInstance;
139
+ tree: TreeInstance;
140
+ itemId: string;
141
+ prev?: MayReturnNull<ItemInstance[Key]>;
142
+ };
143
+
144
+ export type TreeInstanceOpts<
145
+ TreeInstance extends FunctionMap = FunctionMap,
146
+ Key extends keyof TreeInstance = any,
147
+ > = {
148
+ tree: TreeInstance;
149
+ prev?: MayReturnNull<TreeInstance[Key]>;
150
+ };
151
+
126
152
  export type FeatureImplementation<
127
153
  T = any,
128
- D extends FeatureDef = any,
129
- F extends FeatureDef = EmptyFeatureDef,
154
+ SelfFeatureDef extends FeatureDef = any,
155
+ DepFeaturesDef extends FeatureDef = any,
156
+ // /** @internal */
157
+ // AllFeatures extends FeatureDef = MergedFeatures<
158
+ // DepFeaturesDef | SelfFeatureDef
159
+ // >,
160
+ // /** @internal */
161
+ // DepFeatures extends FeatureDef = MergedFeatures<DepFeaturesDef>,
130
162
  > = {
131
163
  key?: string;
132
164
  deps?: string[];
133
165
  overwrites?: string[];
134
166
 
135
167
  stateHandlerNames?: Partial<
136
- Record<keyof MergedFeatures<F>["state"], keyof MergedFeatures<F>["config"]>
168
+ Record<
169
+ keyof MergedFeatures<DepFeaturesDef>["state"],
170
+ keyof MergedFeatures<DepFeaturesDef>["config"]
171
+ >
137
172
  >;
138
173
 
139
174
  getInitialState?: (
140
- initialState: Partial<MergedFeatures<F>["state"]>,
141
- tree: MergedFeatures<F>["treeInstance"],
142
- ) => Partial<D["state"] & MergedFeatures<F>["state"]>;
175
+ initialState: Partial<MergedFeatures<DepFeaturesDef>["state"]>,
176
+ tree: MergedFeatures<DepFeaturesDef>["treeInstance"],
177
+ ) => Partial<
178
+ SelfFeatureDef["state"] & MergedFeatures<DepFeaturesDef>["state"]
179
+ >;
143
180
 
144
181
  getDefaultConfig?: (
145
- defaultConfig: Partial<MergedFeatures<F>["config"]>,
146
- tree: MergedFeatures<F>["treeInstance"],
147
- ) => Partial<D["config"] & MergedFeatures<F>["config"]>;
148
-
149
- createTreeInstance?: (
150
- prev: MergedFeatures<F>["treeInstance"],
151
- instance: MergedFeatures<F>["treeInstance"],
152
- ) => D["treeInstance"] & MergedFeatures<F>["treeInstance"];
153
-
154
- createItemInstance?: (
155
- prev: MergedFeatures<F>["itemInstance"],
156
- item: MergedFeatures<F>["itemInstance"],
157
- tree: MergedFeatures<F>["treeInstance"],
158
- itemId: string,
159
- ) => D["itemInstance"] & MergedFeatures<F>["itemInstance"];
182
+ defaultConfig: Partial<MergedFeatures<DepFeaturesDef>["config"]>,
183
+ tree: MergedFeatures<DepFeaturesDef>["treeInstance"],
184
+ ) => Partial<
185
+ SelfFeatureDef["config"] & MergedFeatures<DepFeaturesDef>["config"]
186
+ >;
187
+
188
+ treeInstance?: {
189
+ [key in keyof (SelfFeatureDef["treeInstance"] &
190
+ MergedFeatures<DepFeaturesDef>["treeInstance"])]?: (
191
+ opts: TreeInstanceOpts<
192
+ SelfFeatureDef["treeInstance"] &
193
+ MergedFeatures<DepFeaturesDef>["treeInstance"],
194
+ key
195
+ >,
196
+ ...args: Parameters<
197
+ (SelfFeatureDef["treeInstance"] &
198
+ MergedFeatures<DepFeaturesDef>["treeInstance"])[key]
199
+ >
200
+ ) => void;
201
+ };
202
+
203
+ itemInstance?: {
204
+ [key in keyof (SelfFeatureDef["itemInstance"] &
205
+ MergedFeatures<DepFeaturesDef>["itemInstance"])]?: (
206
+ opts: ItemInstanceOpts<
207
+ SelfFeatureDef["itemInstance"] &
208
+ MergedFeatures<DepFeaturesDef>["itemInstance"],
209
+ SelfFeatureDef["treeInstance"] &
210
+ MergedFeatures<DepFeaturesDef>["treeInstance"],
211
+ key
212
+ >,
213
+ ...args: Parameters<
214
+ (SelfFeatureDef["itemInstance"] &
215
+ MergedFeatures<DepFeaturesDef>["itemInstance"])[key]
216
+ >
217
+ ) => void;
218
+ };
160
219
 
161
220
  onTreeMount?: (
162
- instance: MergedFeatures<F>["treeInstance"],
221
+ instance: MergedFeatures<DepFeaturesDef>["treeInstance"],
163
222
  treeElement: HTMLElement,
164
223
  ) => void;
165
224
 
166
225
  onTreeUnmount?: (
167
- instance: MergedFeatures<F>["treeInstance"],
226
+ instance: MergedFeatures<DepFeaturesDef>["treeInstance"],
168
227
  treeElement: HTMLElement,
169
228
  ) => void;
170
229
 
171
230
  onItemMount?: (
172
- instance: MergedFeatures<F>["itemInstance"],
231
+ instance: MergedFeatures<DepFeaturesDef>["itemInstance"],
173
232
  itemElement: HTMLElement,
174
- tree: MergedFeatures<F>["treeInstance"],
233
+ tree: MergedFeatures<DepFeaturesDef>["treeInstance"],
175
234
  ) => void;
176
235
 
177
236
  onItemUnmount?: (
178
- instance: MergedFeatures<F>["itemInstance"],
237
+ instance: MergedFeatures<DepFeaturesDef>["itemInstance"],
179
238
  itemElement: HTMLElement,
180
- tree: MergedFeatures<F>["treeInstance"],
239
+ tree: MergedFeatures<DepFeaturesDef>["treeInstance"],
181
240
  ) => void;
182
241
 
183
- hotkeys?: HotkeysConfig<T, D>;
242
+ hotkeys?: HotkeysConfig<T, SelfFeatureDef>;
184
243
  };
@@ -0,0 +1,2 @@
1
+ export const throwError = (message: string) =>
2
+ Error(`Headless Tree: ${message}`);
@@ -8,11 +8,15 @@ export const insertItemsAtTarget = <T>(
8
8
  ) => {
9
9
  // add moved items to new common parent, if dropped onto parent
10
10
  if (target.childIndex === null) {
11
- onChangeChildren(target.item, [
11
+ const newChildren = [
12
12
  ...target.item.getChildren().map((item) => item.getId()),
13
13
  ...itemIds,
14
- ]);
15
- // TODO items[0].getTree().rebuildTree();
14
+ ];
15
+ onChangeChildren(target.item, newChildren);
16
+ if (target.item && "updateCachedChildrenIds" in target.item) {
17
+ target.item.updateCachedChildrenIds(newChildren);
18
+ }
19
+ target.item.getTree().rebuildTree();
16
20
  return;
17
21
  }
18
22
 
@@ -26,5 +30,8 @@ export const insertItemsAtTarget = <T>(
26
30
 
27
31
  onChangeChildren(target.item, newChildren);
28
32
 
33
+ if (target.item && "updateCachedChildrenIds" in target.item) {
34
+ target.item.updateCachedChildrenIds(newChildren);
35
+ }
29
36
  target.item.getTree().rebuildTree();
30
37
  };
@@ -4,16 +4,21 @@ export const removeItemsFromParents = <T>(
4
4
  movedItems: ItemInstance<T>[],
5
5
  onChangeChildren: (item: ItemInstance<T>, newChildrenIds: string[]) => void,
6
6
  ) => {
7
- // TODO bulk sibling changes together
8
- for (const item of movedItems) {
9
- const siblings = item.getParent()?.getChildren();
10
- if (siblings) {
11
- onChangeChildren(
12
- item.getParent(),
13
- siblings
14
- .filter((sibling) => sibling.getId() !== item.getId())
15
- .map((i) => i.getId()),
16
- );
7
+ const movedItemsIds = movedItems.map((item) => item.getId());
8
+ const uniqueParents = [
9
+ ...new Set(movedItems.map((item) => item.getParent())),
10
+ ];
11
+
12
+ for (const parent of uniqueParents) {
13
+ const siblings = parent?.getChildren();
14
+ if (siblings && parent) {
15
+ const newChildren = siblings
16
+ .filter((sibling) => !movedItemsIds.includes(sibling.getId()))
17
+ .map((i) => i.getId());
18
+ onChangeChildren(parent, newChildren);
19
+ if (parent && "updateCachedChildrenIds" in parent) {
20
+ parent?.updateCachedChildrenIds(newChildren);
21
+ }
17
22
  }
18
23
  }
19
24
 
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { makeStateUpdater, memo, poll } from "./utils";
3
+
4
+ vi.useFakeTimers({ shouldAdvanceTime: true });
5
+
6
+ describe("utilities", () => {
7
+ describe("memo", () => {
8
+ it("returns same value for same arguments", () => {
9
+ const fn = vi.fn(
10
+ (a: number, b: number, c: number, d: number) => a + b + c + d,
11
+ );
12
+ const memoized = memo((c: number, d: number) => [1, 1, c, d], fn);
13
+ expect(memoized(1, 1)).toBe(4);
14
+ expect(memoized(1, 1)).toBe(4);
15
+ expect(memoized(1, 1)).toBe(4);
16
+ expect(fn).toHaveBeenCalledTimes(1);
17
+ });
18
+
19
+ it("returns different values for different arguments", () => {
20
+ const fn = vi.fn(
21
+ (a: number, b: number, c: number, d: number) => a + b + c + d,
22
+ );
23
+ const memoized = memo((c: number, d: number) => [1, 1, c, d], fn);
24
+ expect(memoized(1, 1)).toBe(4);
25
+ expect(memoized(1, 2)).toBe(5);
26
+ expect(memoized(1, 2)).toBe(5);
27
+ expect(fn).toHaveBeenCalledTimes(2);
28
+ });
29
+ });
30
+
31
+ describe("makeStateUpdater", () => {
32
+ it("updates the state correctly", () => {
33
+ const instance = {
34
+ setState: vi.fn((updater) => {
35
+ const oldState = { focusedItem: "oldValue" };
36
+ const newState = updater(oldState);
37
+ return newState;
38
+ }),
39
+ };
40
+
41
+ const updater = makeStateUpdater("focusedItem", instance);
42
+ updater("newValue");
43
+
44
+ expect(instance.setState).toHaveBeenCalledTimes(1);
45
+ expect(instance.setState).toHaveBeenCalledWith(expect.any(Function));
46
+ const stateUpdateFn = instance.setState.mock.calls[0][0];
47
+ expect(stateUpdateFn({ focusedItem: "oldValue" })).toEqual({
48
+ focusedItem: "newValue",
49
+ });
50
+ });
51
+
52
+ it("updates the state using a function updater", () => {
53
+ const instance = {
54
+ setState: vi.fn((updater) => {
55
+ const oldState = { focusedItem: "oldValue" };
56
+ const newState = updater(oldState);
57
+ return newState;
58
+ }),
59
+ };
60
+
61
+ const updater = makeStateUpdater("focusedItem", instance);
62
+ updater((prev) => `${prev}Updated`);
63
+
64
+ expect(instance.setState).toHaveBeenCalledTimes(1);
65
+ expect(instance.setState).toHaveBeenCalledWith(expect.any(Function));
66
+ const stateUpdateFn = instance.setState.mock.calls[0][0];
67
+ expect(stateUpdateFn({ focusedItem: "oldValue" })).toEqual({
68
+ focusedItem: "oldValueUpdated",
69
+ });
70
+ });
71
+ });
72
+
73
+ describe("poll", () => {
74
+ it("resolves when the condition is met within the timeout", async () => {
75
+ const condition = vi
76
+ .fn()
77
+ .mockReturnValueOnce(false)
78
+ .mockReturnValueOnce(true);
79
+ await expect(poll(condition, 50, 200)).resolves.toBeUndefined();
80
+ expect(condition).toHaveBeenCalledTimes(2);
81
+ });
82
+
83
+ it("resolves immediately if the condition is already met", async () => {
84
+ const condition = vi.fn().mockReturnValue(true);
85
+ await expect(poll(condition, 50, 200)).resolves.toBeUndefined();
86
+ expect(condition).toHaveBeenCalledTimes(1);
87
+ });
88
+ });
89
+ });
package/src/utils.ts CHANGED
@@ -1,16 +1,16 @@
1
- import { TreeState, Updater } from "./types/core";
1
+ import { SetStateFn, TreeState, Updater } from "./types/core";
2
2
 
3
3
  export type NoInfer<T> = [T][T extends any ? 0 : never];
4
4
 
5
- export const memo = <D extends readonly any[], R>(
5
+ export const memo = <D extends readonly any[], P extends readonly any[], R>(
6
+ deps: (...args: [...P]) => [...D],
6
7
  fn: (...args: [...D]) => R,
7
- deps: () => [...D],
8
8
  ) => {
9
9
  let value: R | undefined;
10
10
  let oldDeps: D | null = null;
11
11
 
12
- return () => {
13
- const newDeps = deps();
12
+ return (...a: [...P]) => {
13
+ const newDeps = deps(...a);
14
14
 
15
15
  if (!value) {
16
16
  value = fn(...newDeps);
@@ -41,7 +41,7 @@ export function functionalUpdate<T>(updater: Updater<T>, input: T): T {
41
41
  export function makeStateUpdater<K extends keyof TreeState<any>>(
42
42
  key: K,
43
43
  instance: unknown,
44
- ) {
44
+ ): SetStateFn<TreeState<any>[K]> {
45
45
  return (updater: Updater<TreeState<any>[K]>) => {
46
46
  (instance as any).setState(<TTableState>(old: TTableState) => {
47
47
  return {
package/tsconfig.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "extends": "../../tsconfig.json",
3
3
  "include": ["./src/**/*"],
4
+ "exclude": ["./src/**/*.spec.tsx", "./src/**/*.spec.ts", "./src/**/*.stories.tsx"],
4
5
  "compilerOptions": {
5
6
  "outDir": "lib/esm"
6
7
  }
@@ -0,0 +1,6 @@
1
+ // eslint-disable-next-line import/no-extraneous-dependencies
2
+ import { defineConfig } from "vitest/config";
3
+
4
+ export default defineConfig({
5
+ test: {},
6
+ });