@rainbow-oh/yee-c 0.6.4 → 0.8.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 (145) hide show
  1. package/dist/cjs/Button/style/index.less +5 -4
  2. package/dist/cjs/Drawer/drawer.js +1 -1
  3. package/dist/cjs/Drawer/interface.d.ts +1 -1
  4. package/dist/cjs/Drawer/style/index.less +1 -0
  5. package/dist/cjs/Form/form-store.d.ts +2 -1
  6. package/dist/cjs/Form/form-store.js +32 -17
  7. package/dist/cjs/List/demo/virtual.d.ts +3 -0
  8. package/dist/cjs/List/demo/virtual.js +53 -0
  9. package/dist/cjs/List/hooks/useListKeyboard.d.ts +28 -0
  10. package/dist/cjs/List/hooks/useListKeyboard.js +188 -0
  11. package/dist/cjs/List/interface.d.ts +29 -2
  12. package/dist/cjs/List/list.js +141 -21
  13. package/dist/cjs/List/style/index.less +24 -0
  14. package/dist/cjs/Select/demo/columns.d.ts +3 -0
  15. package/dist/cjs/Select/demo/columns.js +62 -0
  16. package/dist/cjs/Select/hooks/useSelectKeyboard.d.ts +13 -1
  17. package/dist/cjs/Select/hooks/useSelectKeyboard.js +81 -10
  18. package/dist/cjs/Select/interface.d.ts +30 -3
  19. package/dist/cjs/Select/option.js +4 -3
  20. package/dist/cjs/Select/options.js +90 -8
  21. package/dist/cjs/Select/select.js +32 -8
  22. package/dist/cjs/Select/style/index.less +14 -0
  23. package/dist/cjs/Selector/interface.d.ts +4 -0
  24. package/dist/cjs/Selector/selector.js +5 -3
  25. package/dist/cjs/Table/hooks/useColumns.js +1 -1
  26. package/dist/cjs/Table/interface.d.ts +1 -1
  27. package/dist/cjs/Table/style/index.less +35 -11
  28. package/dist/cjs/Table/table.js +57 -7
  29. package/dist/cjs/Table/tbody/cell.js +1 -1
  30. package/dist/cjs/Table/util/header.d.ts +3 -1
  31. package/dist/cjs/Table/util/header.js +30 -3
  32. package/dist/cjs/Tree/demo/draggable.js +2 -0
  33. package/dist/cjs/Tree/interface.d.ts +46 -23
  34. package/dist/cjs/Tree/tree-node.d.ts +1 -1
  35. package/dist/cjs/Tree/tree-node.js +30 -23
  36. package/dist/cjs/Tree/tree.d.ts +13 -12
  37. package/dist/cjs/Tree/tree.js +452 -227
  38. package/dist/cjs/Tree/utils/tree-tools.d.ts +43 -10
  39. package/dist/cjs/Tree/utils/tree-tools.js +129 -91
  40. package/dist/cjs/locale/context.d.ts +5 -4
  41. package/dist/cjs/locale/context.js +15 -8
  42. package/dist/cjs/locale/interface.d.ts +20 -0
  43. package/dist/cjs/locale/util.d.ts +3 -3
  44. package/dist/cjs/style/color.css +3 -0
  45. package/dist/cjs/style/size.css +2 -1
  46. package/dist/cjs/style/themes.css +1 -1
  47. package/dist/cjs/style/variables.css +6 -2
  48. package/dist/cjs/utils/isEqual.js +5 -7
  49. package/dist/cjs/version.d.ts +1 -1
  50. package/dist/cjs/version.js +1 -1
  51. package/dist/esm/Button/style/index.less +5 -4
  52. package/dist/esm/Drawer/drawer.js +2 -1
  53. package/dist/esm/Drawer/interface.d.ts +1 -1
  54. package/dist/esm/Drawer/style/index.less +1 -0
  55. package/dist/esm/Form/form-store.d.ts +2 -1
  56. package/dist/esm/Form/form-store.js +40 -19
  57. package/dist/esm/List/demo/virtual.d.ts +3 -0
  58. package/dist/esm/List/demo/virtual.js +22 -0
  59. package/dist/esm/List/hooks/useListKeyboard.d.ts +28 -0
  60. package/dist/esm/List/hooks/useListKeyboard.js +173 -0
  61. package/dist/esm/List/interface.d.ts +29 -2
  62. package/dist/esm/List/list.js +171 -14
  63. package/dist/esm/List/style/index.less +24 -0
  64. package/dist/esm/Select/demo/columns.d.ts +3 -0
  65. package/dist/esm/Select/demo/columns.js +36 -0
  66. package/dist/esm/Select/hooks/useSelectKeyboard.d.ts +13 -1
  67. package/dist/esm/Select/hooks/useSelectKeyboard.js +91 -14
  68. package/dist/esm/Select/interface.d.ts +30 -3
  69. package/dist/esm/Select/option.js +6 -2
  70. package/dist/esm/Select/options.js +114 -32
  71. package/dist/esm/Select/select.js +40 -11
  72. package/dist/esm/Select/style/index.less +14 -0
  73. package/dist/esm/Selector/interface.d.ts +4 -0
  74. package/dist/esm/Selector/selector.js +10 -4
  75. package/dist/esm/Table/hooks/useColumns.js +1 -1
  76. package/dist/esm/Table/interface.d.ts +1 -1
  77. package/dist/esm/Table/style/index.less +35 -11
  78. package/dist/esm/Table/table.js +54 -2
  79. package/dist/esm/Table/tbody/cell.js +1 -1
  80. package/dist/esm/Table/util/header.d.ts +3 -1
  81. package/dist/esm/Table/util/header.js +30 -2
  82. package/dist/esm/Tree/demo/draggable.js +2 -0
  83. package/dist/esm/Tree/interface.d.ts +46 -23
  84. package/dist/esm/Tree/tree-node.d.ts +1 -1
  85. package/dist/esm/Tree/tree-node.js +26 -31
  86. package/dist/esm/Tree/tree.d.ts +13 -12
  87. package/dist/esm/Tree/tree.js +572 -163
  88. package/dist/esm/Tree/utils/tree-tools.d.ts +43 -10
  89. package/dist/esm/Tree/utils/tree-tools.js +219 -104
  90. package/dist/esm/locale/context.d.ts +5 -4
  91. package/dist/esm/locale/context.js +25 -9
  92. package/dist/esm/locale/interface.d.ts +20 -0
  93. package/dist/esm/locale/util.d.ts +3 -3
  94. package/dist/esm/style/color.css +3 -0
  95. package/dist/esm/style/size.css +2 -1
  96. package/dist/esm/style/themes.css +1 -1
  97. package/dist/esm/style/variables.css +6 -2
  98. package/dist/esm/utils/isEqual.js +5 -7
  99. package/dist/esm/version.d.ts +1 -1
  100. package/dist/esm/version.js +1 -1
  101. package/dist/umd/yee-c.min.css +4 -4
  102. package/dist/umd/yee-c.min.js +1 -1
  103. package/package.json +1 -1
  104. package/src/Button/style/index.less +5 -4
  105. package/src/Drawer/drawer.tsx +1 -1
  106. package/src/Drawer/interface.ts +1 -0
  107. package/src/Drawer/style/index.less +1 -0
  108. package/src/Form/form-store.ts +42 -15
  109. package/src/List/demo/virtual.tsx +20 -0
  110. package/src/List/hooks/useListKeyboard.ts +235 -0
  111. package/src/List/index.en-US.md +6 -0
  112. package/src/List/index.zh-CN.md +6 -0
  113. package/src/List/interface.ts +29 -2
  114. package/src/List/list.tsx +157 -19
  115. package/src/List/style/index.less +24 -0
  116. package/src/Select/demo/columns.tsx +35 -0
  117. package/src/Select/hooks/useSelectKeyboard.ts +112 -15
  118. package/src/Select/index.en-US.md +4 -0
  119. package/src/Select/index.zh-CN.md +4 -0
  120. package/src/Select/interface.ts +31 -4
  121. package/src/Select/option.tsx +9 -3
  122. package/src/Select/options.tsx +119 -26
  123. package/src/Select/select.tsx +41 -8
  124. package/src/Select/style/index.less +14 -0
  125. package/src/Selector/interface.ts +4 -0
  126. package/src/Selector/selector.tsx +8 -3
  127. package/src/Table/hooks/useColumns.ts +1 -1
  128. package/src/Table/interface.ts +1 -1
  129. package/src/Table/style/index.less +35 -11
  130. package/src/Table/table.tsx +60 -1
  131. package/src/Table/tbody/cell.tsx +1 -1
  132. package/src/Table/util/header.ts +48 -2
  133. package/src/Tree/demo/draggable.tsx +2 -0
  134. package/src/Tree/interface.ts +46 -23
  135. package/src/Tree/tree-node.tsx +26 -29
  136. package/src/Tree/tree.tsx +573 -274
  137. package/src/Tree/utils/tree-tools.ts +231 -125
  138. package/src/locale/_README.md +22 -13
  139. package/src/locale/context.tsx +33 -16
  140. package/src/locale/interface.ts +24 -0
  141. package/src/locale/util.ts +6 -6
  142. package/src/style/color.css +3 -0
  143. package/src/style/size.css +2 -1
  144. package/src/utils/isEqual.ts +5 -7
  145. package/src/version.ts +1 -1
package/src/Tree/tree.tsx CHANGED
@@ -2,17 +2,19 @@ import clsx from 'clsx';
2
2
  import React, {
3
3
  ChangeEvent,
4
4
  createContext,
5
- useCallback,
6
5
  useContext,
7
6
  useMemo,
8
7
  useRef,
9
8
  useState,
10
9
  } from 'react';
10
+ import { AnimatePresence, motion } from 'motion/react';
11
11
  import { GlobalContext } from '../Config-Provider';
12
+ import useEvent from '../hooks/useEvent';
12
13
  import useMergedState from '../hooks/useMergedState';
14
+ import useVirtualList from '../hooks/useVirtualList';
13
15
  import mergeContextToProps from '../utils/mergeContextToProps';
14
- import type { DragPosition, TreeProps } from './interface';
15
- import TreeNode from './tree-node';
16
+ import type { DragPosition, TreeNode, TreeProps } from './interface';
17
+ import TreeNodeComp from './tree-node';
16
18
  import { getCheckedKeys, tree2array } from './utils/tree-tools';
17
19
 
18
20
  import './style/index.less';
@@ -23,7 +25,7 @@ const DEFAULTFIELDNAMES = {
23
25
  children: 'children' as const,
24
26
  };
25
27
 
26
- type DropState = { key: string | number; position: DragPosition } | null;
28
+ type DropState = { uid: string; position: DragPosition } | null;
27
29
 
28
30
  export const TreeContext = createContext<{
29
31
  prefixCls?: string;
@@ -32,26 +34,28 @@ export const TreeContext = createContext<{
32
34
  icon?: React.ReactNode | ((props: any) => React.ReactNode);
33
35
  showLine?: boolean;
34
36
  switcherIcon?: [React.ReactNode, React.ReactNode];
35
- selectedKeys: Array<string | number>;
36
- checkedKeys: Array<string | number>;
37
- expandedKeys: Array<string | number>;
37
+ selectedUids: Set<string>;
38
+ checkedUids: Set<string>;
39
+ indeterminateUids: Set<string>;
40
+ expandedUids: Set<string>;
38
41
  draggable?: boolean;
39
- draggingKey: string | number | null;
42
+ draggingUid: string | null;
40
43
  dropState: DropState;
41
- onCheck: (event: ChangeEvent<HTMLInputElement>) => void;
42
- onSelect: (key: string | number) => void;
43
- onExpand: (key: string | number) => void;
44
- onNodeDragStart: (key: string | number, e: React.DragEvent) => void;
45
- onNodeDragOver: (key: string | number, e: React.DragEvent) => void;
46
- onNodeDragLeave: (key: string | number, e: React.DragEvent) => void;
47
- onNodeDrop: (key: string | number, e: React.DragEvent) => void;
44
+ onCheck: (event: ChangeEvent<HTMLInputElement>, uid: string) => void;
45
+ onSelect: (uid: string) => void;
46
+ onExpand: (uid: string) => void;
47
+ onNodeDragStart: (uid: string, e: React.DragEvent) => void;
48
+ onNodeDragOver: (uid: string, e: React.DragEvent) => void;
49
+ onNodeDragLeave: (uid: string, e: React.DragEvent) => void;
50
+ onNodeDrop: (uid: string, e: React.DragEvent) => void;
48
51
  }>({
49
52
  showIcon: true,
50
- selectedKeys: [],
51
- checkedKeys: [],
52
- expandedKeys: [],
53
+ selectedUids: new Set(),
54
+ checkedUids: new Set(),
55
+ indeterminateUids: new Set(),
56
+ expandedUids: new Set(),
53
57
  draggable: false,
54
- draggingKey: null,
58
+ draggingUid: null,
55
59
  dropState: null,
56
60
  onCheck: () => {},
57
61
  onSelect: () => {},
@@ -62,11 +66,115 @@ export const TreeContext = createContext<{
62
66
  onNodeDrop: () => {},
63
67
  });
64
68
 
69
+ // ---------------------------------------------------------------------------
70
+ // Helpers: convert between external user keys and internal uids
71
+ // ---------------------------------------------------------------------------
72
+
73
+ const EMPTY_ARRAY: string[] = [];
74
+
75
+ /**
76
+ * Given a list of user-provided keys, find matching uids.
77
+ * A key may match multiple nodes (non-unique keys), so we return all matches.
78
+ * Returns an array of uid strings (for use with useMergedState).
79
+ */
80
+ function keysToUidArray(
81
+ keys: Array<string | number> | undefined,
82
+ flatItems: TreeNode<any>[],
83
+ ): string[] | undefined {
84
+ if (!keys) return undefined;
85
+ if (keys.length === 0) return EMPTY_ARRAY;
86
+ const keySet = new Set(keys.map(String));
87
+ const uids: string[] = [];
88
+ for (const item of flatItems) {
89
+ if (keySet.has(String(item.key))) {
90
+ uids.push(item.uid);
91
+ }
92
+ }
93
+ return uids;
94
+ }
95
+
96
+ /**
97
+ * Hook that memoizes a uid array derived from external keys.
98
+ * Only returns a new reference when the actual uid content changes.
99
+ * This prevents useMergedState from seeing a "new value" every render.
100
+ */
101
+ function useStableUidArray(
102
+ keys: Array<string | number> | undefined,
103
+ flatItems: TreeNode<any>[],
104
+ ): string[] | undefined {
105
+ const ref = useRef<{
106
+ keys: Array<string | number> | undefined;
107
+ result: string[] | undefined;
108
+ }>({
109
+ keys: undefined,
110
+ result: undefined,
111
+ });
112
+
113
+ // Only recompute if external keys reference or flatItems changed
114
+ const computed = useMemo(
115
+ () => keysToUidArray(keys, flatItems),
116
+ [keys, flatItems],
117
+ );
118
+
119
+ // Stable reference: reuse previous if content is identical
120
+ if (computed === undefined) {
121
+ ref.current = { keys, result: undefined };
122
+ return undefined;
123
+ }
124
+
125
+ const prev = ref.current.result;
126
+ if (
127
+ prev &&
128
+ prev.length === computed.length &&
129
+ prev.every((v, i) => v === computed[i])
130
+ ) {
131
+ return prev;
132
+ }
133
+
134
+ ref.current = { keys, result: computed };
135
+ return computed;
136
+ }
137
+
138
+ /**
139
+ * Convert an array of uids to user keys (for external callbacks).
140
+ */
141
+ function uidsToKeys(
142
+ uids: string[],
143
+ map: Map<string, TreeNode<any>>,
144
+ ): Array<string | number> {
145
+ const keys: Array<string | number> = [];
146
+ for (const uid of uids) {
147
+ const node = map.get(uid);
148
+ if (node) keys.push(node.key);
149
+ }
150
+ return keys;
151
+ }
152
+
153
+ /** Build a Set from an array (avoids downlevelIteration issues with Set constructor). */
154
+ function arrayToSet(arr: string[]): Set<string> {
155
+ const s = new Set<string>();
156
+ for (const item of arr) {
157
+ s.add(item);
158
+ }
159
+ return s;
160
+ }
161
+
162
+ /** Convert a Set to an array (avoids spread/iteration issues). */
163
+ function setToArray(s: Set<string>): string[] {
164
+ const arr: string[] = [];
165
+ s.forEach((v) => arr.push(v));
166
+ return arr;
167
+ }
168
+
169
+ // ---------------------------------------------------------------------------
170
+ // Tree Component
171
+ // ---------------------------------------------------------------------------
172
+
65
173
  const Tree = <T extends Record<string, unknown> = any>(
66
174
  baseprops: TreeProps<T>,
67
175
  ) => {
68
176
  const { tree } = useContext(GlobalContext);
69
- // @ts-expect-error - tree context type may not match TreeProps<T> exactly, mergeContextToProps handles this
177
+ // @ts-expect-error - tree context type may not match TreeProps<T> exactly
70
178
  const props = mergeContextToProps(baseprops, tree);
71
179
 
72
180
  const {
@@ -94,279 +202,471 @@ const Tree = <T extends Record<string, unknown> = any>(
94
202
  onSelect,
95
203
  onCheck,
96
204
  onExpand,
205
+ virtual = false,
206
+ height,
207
+ itemHeight = 28,
97
208
  } = props;
98
209
 
99
- const [mergedSelectedKeys, setMergedSelectedKeys] = useMergedState<
100
- Array<string | number>
101
- >([], {
102
- value: selectedKeys,
103
- defaultValue: defaultSelectedKeys,
104
- });
105
-
106
- const [mergedCheckedKeys, setMergedCheckedKeys] = useMergedState<
107
- Array<string | number>
108
- >([], {
109
- value: checkedKeys,
110
- defaultValue: defaultCheckedKeys,
111
- });
112
-
210
+ // ======================== Data ========================
113
211
  const [flatItems, itemMap] = useMemo(
114
212
  () => tree2array(dataSource ?? [], fieldNames),
115
213
  [dataSource, fieldNames],
116
214
  );
117
- const allParentKeys = useMemo(() => {
118
- const keys = [] as Array<string | number>;
119
- flatItems.forEach((item) => {
120
- if (!item.isLeaf) {
121
- keys.push(item.key);
122
- }
123
- });
124
- return keys;
215
+
216
+ // All parent uids (for defaultExpandAll)
217
+ const allParentUidArray = useMemo(() => {
218
+ const uids: string[] = [];
219
+ for (const item of flatItems) {
220
+ if (!item.isLeaf) uids.push(item.uid);
221
+ }
222
+ return uids;
125
223
  }, [flatItems]);
126
224
 
127
- const [mergedExpandedKeys, setMergedExpandedKeys] = useMergedState<
128
- Array<string | number>
129
- >(
130
- () => {
131
- return defaultExpandAll ? allParentKeys : [];
225
+ // ==================== State (array-based for useMergedState compatibility) ====================
226
+ // useMergedState works with arrays (serializable, isEqual-friendly).
227
+ // We derive Sets via useMemo for O(1) lookups in render/handlers.
228
+ // useStableUidArray ensures stable references to prevent infinite re-render loops.
229
+
230
+ const stableExpandedValue = useStableUidArray(expandedKeys, flatItems);
231
+ const stableExpandedDefault = useStableUidArray(defaultExpandedKeys, flatItems);
232
+ const stableSelectedValue = useStableUidArray(selectedKeys, flatItems);
233
+ const stableSelectedDefault = useStableUidArray(defaultSelectedKeys, flatItems);
234
+ const stableCheckedValue = useStableUidArray(checkedKeys, flatItems);
235
+ const stableCheckedDefault = useStableUidArray(defaultCheckedKeys, flatItems);
236
+
237
+ // --- Expanded ---
238
+ const [expandedUidArray, setExpandedUidArray] = useMergedState<string[]>(
239
+ () => (defaultExpandAll ? allParentUidArray : []),
240
+ {
241
+ value: stableExpandedValue,
242
+ defaultValue: stableExpandedDefault,
132
243
  },
244
+ );
245
+ const expandedUidSet = useMemo(
246
+ () => arrayToSet(expandedUidArray),
247
+ [expandedUidArray],
248
+ );
249
+
250
+ // --- Selected ---
251
+ const [selectedUidArray, setSelectedUidArray] = useMergedState<string[]>(
252
+ [],
133
253
  {
134
- value: expandedKeys,
135
- defaultValue: defaultExpandedKeys,
254
+ value: stableSelectedValue,
255
+ defaultValue: stableSelectedDefault,
136
256
  },
137
257
  );
258
+ const selectedUidSet = useMemo(
259
+ () => arrayToSet(selectedUidArray),
260
+ [selectedUidArray],
261
+ );
262
+
263
+ // --- Checked ---
264
+ const [checkedUidArray, setCheckedUidArray] = useMergedState<string[]>([], {
265
+ value: stableCheckedValue,
266
+ defaultValue: stableCheckedDefault,
267
+ });
268
+ const checkedUidSet = useMemo(
269
+ () => arrayToSet(checkedUidArray),
270
+ [checkedUidArray],
271
+ );
138
272
 
139
- const handleCheck = useCallback(
140
- (event: ChangeEvent<HTMLInputElement>) => {
141
- const key = event.target.value;
273
+ // --- Indeterminate (half-select) ---
274
+ // A non-leaf node is indeterminate if:
275
+ // - It is NOT itself checked
276
+ // - At least one of its descendants IS checked
277
+ const indeterminateUidSet = useMemo(() => {
278
+ const result = new Set<string>();
279
+ for (const item of flatItems) {
280
+ if (
281
+ !item.isLeaf &&
282
+ !checkedUidSet.has(item.uid) &&
283
+ item.childUids &&
284
+ item.childUids.some((cuid) => checkedUidSet.has(cuid))
285
+ ) {
286
+ result.add(item.uid);
287
+ }
288
+ }
289
+ return result;
290
+ }, [flatItems, checkedUidSet]);
291
+
292
+ // ==================== Visible Items ====================
293
+ // A node is visible if it's a root OR its parent is both visible and expanded.
294
+ // flatItems is in pre-order so we can compute in a single forward pass.
295
+ const visibleItems = useMemo(() => {
296
+ const visibleSet = new Set<string>();
297
+ const result: TreeNode<T>[] = [];
298
+
299
+ for (const item of flatItems) {
300
+ if (item.depth === 0) {
301
+ visibleSet.add(item.uid);
302
+ result.push(item);
303
+ } else if (
304
+ item.pUid &&
305
+ visibleSet.has(item.pUid) &&
306
+ expandedUidSet.has(item.pUid)
307
+ ) {
308
+ visibleSet.add(item.uid);
309
+ result.push(item);
310
+ }
311
+ }
312
+
313
+ return result;
314
+ }, [flatItems, expandedUidSet]);
315
+
316
+ // ==================== Handlers ====================
317
+ // All handlers use useEvent for stable references — they always read
318
+ // the latest state via closure without being listed as deps.
319
+
320
+ const handleCheck = useEvent(
321
+ (event: ChangeEvent<HTMLInputElement>, uid: string) => {
142
322
  const checked = event.target.checked;
143
- const treeNode = itemMap.get(key);
323
+ const treeNode = itemMap.get(uid);
144
324
 
145
- const newKeys = getCheckedKeys({
325
+ const newUidSet = getCheckedKeys({
146
326
  checked,
147
- keys: mergedCheckedKeys,
148
- key: key,
149
- map: itemMap,
327
+ keys: checkedUidSet,
328
+ uid,
329
+ map: itemMap as Map<string, TreeNode<unknown>>,
150
330
  });
151
331
 
152
- const nodes = newKeys
153
- .map((key) => itemMap.get(key as string)?.original)
154
- .filter((node): node is T => node !== undefined);
155
-
156
- setMergedCheckedKeys(newKeys);
332
+ const newUidArr = setToArray(newUidSet);
333
+ setCheckedUidArray(newUidArr);
157
334
 
158
- const originalNode = treeNode?.original;
159
- if (originalNode) {
160
- onCheck?.(newKeys, {
335
+ if (treeNode && onCheck) {
336
+ const externalKeys = uidsToKeys(newUidArr, itemMap);
337
+ const nodes: T[] = [];
338
+ newUidSet.forEach((u) => {
339
+ const n = itemMap.get(u)?.original;
340
+ if (n) nodes.push(n);
341
+ });
342
+ onCheck(externalKeys, {
161
343
  checked,
162
344
  checkedNodes: nodes,
163
- node: originalNode,
345
+ node: treeNode.original,
164
346
  });
165
347
  }
166
348
  },
167
- [mergedCheckedKeys, itemMap, onCheck],
168
349
  );
169
350
 
170
- const handleSelect = useCallback(
171
- (key: string | number) => {
172
- const selected = mergedSelectedKeys.includes(key);
173
- const newKeys = multiple
174
- ? selected
175
- ? [...mergedSelectedKeys.filter((k) => k !== key)]
176
- : [...mergedSelectedKeys, key]
177
- : selected
178
- ? []
179
- : [key];
180
-
181
- setMergedSelectedKeys(newKeys);
182
-
183
- const nodes = newKeys
184
- .map((k) => itemMap.get(k as string)?.original)
185
- .filter((node): node is T => node !== undefined);
186
- const treeNode = itemMap.get(key as string);
187
- const originalNode = treeNode?.original;
188
-
189
- if (originalNode) {
190
- onSelect?.(newKeys, {
191
- selected: !selected,
192
- selectedNodes: nodes,
193
- node: originalNode,
194
- });
195
- }
196
- },
197
- [mergedSelectedKeys, itemMap, multiple, onSelect],
198
- );
199
-
200
- const handleExpand = useCallback(
201
- (key: string | number) => {
202
- const expanded = mergedExpandedKeys.includes(key);
203
- const newKeys = expanded
204
- ? [...mergedExpandedKeys.filter((k) => k !== key)]
205
- : [...mergedExpandedKeys, key];
206
-
207
- setMergedExpandedKeys(newKeys);
351
+ const handleSelect = useEvent((uid: string) => {
352
+ const selected = selectedUidSet.has(uid);
353
+ let newUidArr: string[];
208
354
 
209
- const nodes = newKeys
210
- .map((k) => itemMap.get(k as string)?.original)
211
- .filter((node): node is T => node !== undefined);
355
+ if (multiple) {
356
+ if (selected) {
357
+ newUidArr = selectedUidArray.filter((u) => u !== uid);
358
+ } else {
359
+ newUidArr = [...selectedUidArray, uid];
360
+ }
361
+ } else {
362
+ newUidArr = selected ? [] : [uid];
363
+ }
364
+
365
+ setSelectedUidArray(newUidArr);
366
+
367
+ const treeNode = itemMap.get(uid);
368
+ if (treeNode && onSelect) {
369
+ const externalKeys = uidsToKeys(newUidArr, itemMap);
370
+ const nodes: T[] = [];
371
+ for (const u of newUidArr) {
372
+ const n = itemMap.get(u)?.original;
373
+ if (n) nodes.push(n);
374
+ }
375
+ onSelect(externalKeys, {
376
+ selected: !selected,
377
+ selectedNodes: nodes,
378
+ node: treeNode.original,
379
+ });
380
+ }
381
+ });
212
382
 
213
- onExpand?.(newKeys, {
383
+ const handleExpand = useEvent((uid: string) => {
384
+ const expanded = expandedUidSet.has(uid);
385
+ const newUidArr = expanded
386
+ ? expandedUidArray.filter((u) => u !== uid)
387
+ : [...expandedUidArray, uid];
388
+
389
+ setExpandedUidArray(newUidArr);
390
+
391
+ if (onExpand) {
392
+ const externalKeys = uidsToKeys(newUidArr, itemMap);
393
+ const treeNode = itemMap.get(uid);
394
+ const nodes: T[] = [];
395
+ for (const u of newUidArr) {
396
+ const n = itemMap.get(u)?.original;
397
+ if (n) nodes.push(n);
398
+ }
399
+ onExpand(externalKeys, {
214
400
  expanded: !expanded,
215
401
  expandedNodes: nodes,
216
- node: nodes,
402
+ node: treeNode!.original,
217
403
  });
218
- },
219
- [mergedExpandedKeys, itemMap, onExpand],
220
- );
404
+ }
405
+ });
221
406
 
222
- // --- drag & drop (native HTML5) ---
223
- // dataTransfer is not reliably readable during dragover, so the drag key
224
- // lives in a ref as the source of truth; a state mirrors it for styling.
225
- const dragKeyRef = useRef<string | number | null>(null);
226
- const [draggingKey, setDraggingKey] = useState<string | number | null>(null);
407
+ // --- Drag & Drop ---
408
+ const dragUidRef = useRef<string | null>(null);
409
+ const [draggingUid, setDraggingUid] = useState<string | null>(null);
227
410
  const [dropState, setDropState] = useState<DropState>(null);
228
411
 
229
- // A drop is a cycle/illegal if the target is the dragged node itself or one
230
- // of its descendants. Covers before/after/inside uniformly.
231
- const isIllegalMove = useCallback(
232
- (dragKey: string | number, dropKey: string | number) => {
233
- if (dragKey === dropKey) return true;
234
- const dropNode = itemMap.get(dropKey as string);
235
- return !!dropNode && dropNode.path.includes(dragKey as string);
236
- },
237
- [itemMap],
238
- );
239
-
240
- const resolvePosition = useCallback(
241
- (rect: DOMRect, clientY: number): DragPosition => {
242
- const offset = (clientY - rect.top) / rect.height;
243
- if (offset < 0.25) return 'before';
244
- if (offset > 0.75) return 'after';
245
- return 'inside';
246
- },
247
- [],
248
- );
249
-
250
- const handleDragStart = useCallback(
251
- (key: string | number, e: React.DragEvent) => {
252
- const node = itemMap.get(key as string);
253
- if (node?.disabled) {
254
- e.preventDefault();
255
- return;
256
- }
257
- dragKeyRef.current = key;
258
- setDraggingKey(key);
259
- if (e.dataTransfer) {
260
- e.dataTransfer.effectAllowed = 'move';
261
- try {
262
- e.dataTransfer.setData('text/plain', String(key));
263
- } catch {
264
- /* some browsers throw on setData in certain modes */
265
- }
266
- }
267
- },
268
- [itemMap],
269
- );
270
-
271
- const handleDragOver = useCallback(
272
- (key: string | number, e: React.DragEvent) => {
273
- const dragKey = dragKeyRef.current;
274
- if (dragKey === null) return;
275
- if (isIllegalMove(dragKey, key)) return;
276
- const dropNode = itemMap.get(key as string);
277
- if (!dropNode || dropNode.disabled) return;
278
-
279
- const position = resolvePosition(
280
- (e.currentTarget as HTMLElement).getBoundingClientRect(),
281
- e.clientY,
282
- );
283
-
284
- const dragNode = itemMap.get(dragKey as string);
285
- if (
286
- allowDrop &&
287
- dragNode &&
288
- allowDrop({
289
- dragNode: dragNode.original,
290
- dropNode: dropNode.original,
291
- position,
292
- }) === false
293
- ) {
294
- return; // not calling preventDefault shows the "no-drop" cursor
295
- }
296
-
297
- e.preventDefault();
298
- if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
299
- setDropState((prev) =>
300
- prev && prev.key === key && prev.position === position
301
- ? prev
302
- : { key, position },
303
- );
304
- },
305
- [itemMap, isIllegalMove, resolvePosition, allowDrop],
306
- );
307
-
308
- const handleDragLeave = useCallback(
309
- // Intentionally a no-op: dragleave fires spuriously when crossing child
310
- // elements. Drop state is cleared in handleDragEnd at the Tree root.
311
- () => {},
312
- [],
313
- );
314
-
315
- const handleDrop = useCallback(
316
- (key: string | number, e: React.DragEvent) => {
412
+ const resolvePosition = (rect: DOMRect, clientY: number): DragPosition => {
413
+ const offset = (clientY - rect.top) / rect.height;
414
+ if (offset < 0.25) return 'before';
415
+ if (offset > 0.75) return 'after';
416
+ return 'inside';
417
+ };
418
+
419
+ const isIllegalMove = (dragUid: string, dropUid: string): boolean => {
420
+ if (dragUid === dropUid) return true;
421
+ const dropNode = itemMap.get(dropUid);
422
+ if (!dropNode) return true;
423
+ let current: TreeNode<T> | undefined = dropNode;
424
+ while (current) {
425
+ if (current.pUid === dragUid) return true;
426
+ current = current.pUid ? itemMap.get(current.pUid) : undefined;
427
+ }
428
+ return false;
429
+ };
430
+
431
+ const handleDragStart = useEvent((uid: string, e: React.DragEvent) => {
432
+ const node = itemMap.get(uid);
433
+ if (node?.disabled) {
317
434
  e.preventDefault();
318
- const dragKey = dragKeyRef.current;
319
- if (dragKey === null) return;
320
-
321
- const position = resolvePosition(
322
- (e.currentTarget as HTMLElement).getBoundingClientRect(),
323
- e.clientY,
324
- );
325
-
326
- if (isIllegalMove(dragKey, key)) return;
327
- const dragNode = itemMap.get(dragKey as string);
328
- const dropNode = itemMap.get(key as string);
329
- if (!dragNode || !dropNode) return;
330
- if (
331
- allowDrop &&
332
- allowDrop({
333
- dragNode: dragNode.original,
334
- dropNode: dropNode.original,
335
- position,
336
- }) === false
337
- ) {
338
- return;
435
+ return;
436
+ }
437
+ dragUidRef.current = uid;
438
+ setDraggingUid(uid);
439
+ if (e.dataTransfer) {
440
+ e.dataTransfer.effectAllowed = 'move';
441
+ try {
442
+ e.dataTransfer.setData('text/plain', uid);
443
+ } catch {
444
+ /* some browsers throw */
339
445
  }
446
+ }
447
+ });
448
+
449
+ const handleDragOver = useEvent((uid: string, e: React.DragEvent) => {
450
+ const dragUid = dragUidRef.current;
451
+ if (dragUid === null) return;
452
+ if (isIllegalMove(dragUid, uid)) return;
453
+ const dropNode = itemMap.get(uid);
454
+ if (!dropNode || dropNode.disabled) return;
455
+
456
+ const position = resolvePosition(
457
+ (e.currentTarget as HTMLElement).getBoundingClientRect(),
458
+ e.clientY,
459
+ );
460
+
461
+ const dragNode = itemMap.get(dragUid);
462
+ if (
463
+ allowDrop &&
464
+ dragNode &&
465
+ allowDrop({
466
+ dragNode: dragNode.original,
467
+ dropNode: dropNode.original,
468
+ position,
469
+ }) === false
470
+ ) {
471
+ return;
472
+ }
473
+
474
+ e.preventDefault();
475
+ if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
476
+ setDropState((prev) =>
477
+ prev && prev.uid === uid && prev.position === position
478
+ ? prev
479
+ : { uid, position },
480
+ );
481
+ });
340
482
 
341
- onDrop?.({
483
+ const handleDragLeave = useEvent(() => {});
484
+
485
+ const handleDrop = useEvent((uid: string, e: React.DragEvent) => {
486
+ e.preventDefault();
487
+ const dragUid = dragUidRef.current;
488
+ if (dragUid === null) return;
489
+
490
+ const position = resolvePosition(
491
+ (e.currentTarget as HTMLElement).getBoundingClientRect(),
492
+ e.clientY,
493
+ );
494
+
495
+ if (isIllegalMove(dragUid, uid)) return;
496
+ const dragNode = itemMap.get(dragUid);
497
+ const dropNode = itemMap.get(uid);
498
+ if (!dragNode || !dropNode) return;
499
+ if (
500
+ allowDrop &&
501
+ allowDrop({
342
502
  dragNode: dragNode.original,
343
503
  dropNode: dropNode.original,
344
- dragKey,
345
- dropKey: key,
346
504
  position,
347
- dropToGap: position !== 'inside',
505
+ }) === false
506
+ ) {
507
+ return;
508
+ }
509
+
510
+ onDrop?.({
511
+ dragNode: dragNode.original,
512
+ dropNode: dropNode.original,
513
+ dragKey: dragNode.key,
514
+ dropKey: dropNode.key,
515
+ dragPath: [...dragNode.path, dragNode.key],
516
+ dropPath: [...dropNode.path, dropNode.key],
517
+ position,
518
+ dropToGap: position !== 'inside',
519
+ });
520
+
521
+ // Auto-expand the target after a drop-inside
522
+ if (position === 'inside') {
523
+ setExpandedUidArray((prev) => {
524
+ if (prev.includes(uid)) return prev;
525
+ return [...prev, uid];
348
526
  });
527
+ }
349
528
 
350
- // Auto-expand the target after a drop-inside so the dragged node is
351
- // visible. Only effective in uncontrolled expandedKeys mode.
352
- if (position === 'inside') {
353
- setMergedExpandedKeys((prev) =>
354
- prev.includes(key) ? prev : [...prev, key],
355
- );
356
- }
529
+ dragUidRef.current = null;
530
+ setDraggingUid(null);
531
+ setDropState(null);
532
+ });
357
533
 
358
- dragKeyRef.current = null;
359
- setDraggingKey(null);
360
- setDropState(null);
361
- },
362
- [itemMap, isIllegalMove, resolvePosition, allowDrop, onDrop],
534
+ const handleDragEnd = useEvent(() => {
535
+ dragUidRef.current = null;
536
+ setDraggingUid(null);
537
+ setDropState(null);
538
+ });
539
+
540
+ // ==================== Context Value (memoized) ====================
541
+ const contextValue = useMemo(
542
+ () => ({
543
+ prefixCls,
544
+ checkable,
545
+ showIcon,
546
+ icon,
547
+ showLine,
548
+ switcherIcon,
549
+ selectedUids: selectedUidSet,
550
+ checkedUids: checkedUidSet,
551
+ indeterminateUids: indeterminateUidSet,
552
+ expandedUids: expandedUidSet,
553
+ draggable,
554
+ draggingUid,
555
+ dropState,
556
+ onCheck: handleCheck,
557
+ onSelect: handleSelect,
558
+ onExpand: handleExpand,
559
+ onNodeDragStart: handleDragStart,
560
+ onNodeDragOver: handleDragOver,
561
+ onNodeDragLeave: handleDragLeave,
562
+ onNodeDrop: handleDrop,
563
+ }),
564
+ [
565
+ prefixCls,
566
+ checkable,
567
+ showIcon,
568
+ icon,
569
+ showLine,
570
+ switcherIcon,
571
+ selectedUidSet,
572
+ checkedUidSet,
573
+ indeterminateUidSet,
574
+ expandedUidSet,
575
+ draggable,
576
+ draggingUid,
577
+ dropState,
578
+ // handlers are stable (useEvent), but listed for correctness
579
+ handleCheck,
580
+ handleSelect,
581
+ handleExpand,
582
+ handleDragStart,
583
+ handleDragOver,
584
+ handleDragLeave,
585
+ handleDrop,
586
+ ],
363
587
  );
364
588
 
365
- const handleDragEnd = useCallback(() => {
366
- dragKeyRef.current = null;
367
- setDraggingKey(null);
368
- setDropState(null);
369
- }, []);
589
+ // ==================== Virtual Scroll ====================
590
+ const useVirtual = virtual && height !== null && height !== undefined;
591
+
592
+ const { start, end, offsetY, totalHeight, viewportRef, onScroll } =
593
+ useVirtualList({
594
+ itemCount: useVirtual ? visibleItems.length : 0,
595
+ itemHeight,
596
+ overscan: 4,
597
+ });
598
+
599
+ // ==================== Render ====================
600
+ if (useVirtual) {
601
+ const slicedItems = visibleItems.slice(start, end);
602
+ return (
603
+ <div
604
+ className={clsx(prefixCls, className)}
605
+ style={style}
606
+ onDragEnd={handleDragEnd}
607
+ >
608
+ <TreeContext.Provider value={contextValue}>
609
+ <div
610
+ ref={viewportRef}
611
+ onScroll={onScroll}
612
+ style={{ height, overflowY: 'auto' }}
613
+ >
614
+ <div style={{ height: totalHeight, position: 'relative' }}>
615
+ {slicedItems.map((node, i) => (
616
+ <TreeNodeComp
617
+ node={node}
618
+ key={node.uid}
619
+ style={{
620
+ position: 'absolute',
621
+ top: 0,
622
+ left: 0,
623
+ width: '100%',
624
+ height: itemHeight,
625
+ transform: `translateY(${offsetY + i * itemHeight}px)`,
626
+ }}
627
+ />
628
+ ))}
629
+ </div>
630
+ </div>
631
+ </TreeContext.Provider>
632
+ </div>
633
+ );
634
+ }
635
+
636
+ // ==================== Render (non-virtual) ====================
637
+ // Group consecutive children of the same parent into segments.
638
+ // Each group animates as a single unit (one height measurement) rather than
639
+ // each node individually, drastically reducing layout thrashing.
640
+ type Segment =
641
+ | { type: 'node'; node: TreeNode<T> }
642
+ | { type: 'group'; pUid: string; nodes: TreeNode<T>[] };
643
+
644
+ const segments: Segment[] = [];
645
+ let currentGroup: { pUid: string; nodes: TreeNode<T>[] } | null = null;
646
+
647
+ for (const node of visibleItems) {
648
+ if (node.depth === 0) {
649
+ // Root nodes are always visible, render directly without animation
650
+ if (currentGroup) {
651
+ segments.push({ type: 'group', ...currentGroup });
652
+ currentGroup = null;
653
+ }
654
+ segments.push({ type: 'node', node });
655
+ } else {
656
+ // Non-root: group consecutive children under the same parent
657
+ if (currentGroup && currentGroup.pUid === node.pUid) {
658
+ currentGroup.nodes.push(node);
659
+ } else {
660
+ if (currentGroup) {
661
+ segments.push({ type: 'group', ...currentGroup });
662
+ }
663
+ currentGroup = { pUid: node.pUid!, nodes: [node] };
664
+ }
665
+ }
666
+ }
667
+ if (currentGroup) {
668
+ segments.push({ type: 'group', ...currentGroup });
669
+ }
370
670
 
371
671
  return (
372
672
  <div
@@ -374,32 +674,31 @@ const Tree = <T extends Record<string, unknown> = any>(
374
674
  style={style}
375
675
  onDragEnd={handleDragEnd}
376
676
  >
377
- <TreeContext.Provider
378
- value={{
379
- prefixCls,
380
- checkable,
381
- showIcon,
382
- icon,
383
- showLine,
384
- switcherIcon,
385
- selectedKeys: mergedSelectedKeys,
386
- checkedKeys: mergedCheckedKeys,
387
- expandedKeys: mergedExpandedKeys,
388
- draggable,
389
- draggingKey,
390
- dropState,
391
- onCheck: handleCheck,
392
- onSelect: handleSelect,
393
- onExpand: handleExpand,
394
- onNodeDragStart: handleDragStart,
395
- onNodeDragOver: handleDragOver,
396
- onNodeDragLeave: handleDragLeave,
397
- onNodeDrop: handleDrop,
398
- }}
399
- >
400
- {flatItems.map((node, index) => (
401
- <TreeNode node={node} key={node.key || index} />
402
- ))}
677
+ <TreeContext.Provider value={contextValue}>
678
+ <AnimatePresence initial={false}>
679
+ {segments.map((segment) => {
680
+ if (segment.type === 'node') {
681
+ return <TreeNodeComp node={segment.node} key={segment.node.uid} />;
682
+ }
683
+ // Keep the wrapper mounted throughout native drag operations.
684
+ // Replacing this tree after dragStart removes the browser's drag
685
+ // source and cancels the drag before dragOver/drop can fire.
686
+ return (
687
+ <motion.div
688
+ key={segment.pUid + '\x01children'}
689
+ initial={{ height: 0, opacity: 0 }}
690
+ animate={{ height: 'auto', opacity: 1 }}
691
+ exit={{ height: 0, opacity: 0 }}
692
+ transition={{ duration: 0.15 }}
693
+ style={{ overflow: 'hidden' }}
694
+ >
695
+ {segment.nodes.map((node) => (
696
+ <TreeNodeComp node={node} key={node.uid} />
697
+ ))}
698
+ </motion.div>
699
+ );
700
+ })}
701
+ </AnimatePresence>
403
702
  </TreeContext.Provider>
404
703
  </div>
405
704
  );